task-loop-store.mjs
963 lines 29.8 KB
Raw
sha256:baa800aedf841dbf32081aa7b2befa288ac33dfc7175ac55014c55c4d8c742f9 docs: move durable-auth freeze/evidence to local developmen… Human 11 days ago
1 /**
2 * Local file-backed Task Loop store (Phase 2G-c-b — read-only list/get).
3 *
4 * Task loops and orchestrator graphs persist in `hub_flow_store.json` vault buckets.
5 * Idempotent starter seed on first read.
6 *
7 * @see docs/TASK-LOOP-STORE-CONTRACT-2G-c.md
8 */
9
10 import fs from 'fs';
11 import path from 'path';
12 import { fileURLToPath } from 'url';
13 import { getRepoRoot } from '../repo-root.mjs';
14 import { loadFlowStore, saveFlowStore } from '../flow/flow-store.mjs';
15 import { highestFlowScope } from '../flow/flow-scope.mjs';
16 import {
17 TASK_KINDS,
18 UID_HASH_REF_RE,
19 SAFE_ARTIFACT_REF_RE,
20 validateTaskRecord,
21 } from './task-store.mjs';
22 import { validateTaskLoopRrule } from './task-loop-rrule.mjs';
23
24 export const STARTER_TASK_LOOPS_DIRNAME = 'task-loops/starter';
25 export const STARTER_ORCHESTRATOR_GRAPHS_DIRNAME = 'orchestrator-graphs/starter';
26 export const STARTER_LOOP_INSTANCES_DIRNAME = 'task-loops/starter/instances';
27
28 /** Parent segments from any package module to bundled starter dirs (hub/bridge, lib/task, …). */
29 const STARTER_LOOPS_REL_FROM_MODULE = ['..', '..', 'task-loops', 'starter'];
30 const STARTER_GRAPHS_REL_FROM_MODULE = ['..', '..', 'orchestrator-graphs', 'starter'];
31 const STARTER_INSTANCES_REL_FROM_MODULE = ['..', '..', 'task-loops', 'starter', 'instances'];
32
33 /**
34 * Resolve bundled starter task-loop JSON directory (Netlify Lambda cwd-safe).
35 *
36 * @param {string | URL} [moduleUrl] `import.meta.url` of a module in this package
37 * @returns {string}
38 */
39 export function resolveStarterTaskLoopsDir(moduleUrl) {
40 if (moduleUrl) {
41 const base = path.dirname(fileURLToPath(moduleUrl));
42 return path.normalize(path.join(base, ...STARTER_LOOPS_REL_FROM_MODULE));
43 }
44 return path.join(getRepoRoot(), STARTER_TASK_LOOPS_DIRNAME);
45 }
46
47 /**
48 * Resolve bundled orchestrator graph starter directory (Netlify Lambda cwd-safe).
49 *
50 * @param {string | URL} [moduleUrl]
51 * @returns {string}
52 */
53 export function resolveStarterOrchestratorGraphsDir(moduleUrl) {
54 if (moduleUrl) {
55 const base = path.dirname(fileURLToPath(moduleUrl));
56 return path.normalize(path.join(base, ...STARTER_GRAPHS_REL_FROM_MODULE));
57 }
58 return path.join(getRepoRoot(), STARTER_ORCHESTRATOR_GRAPHS_DIRNAME);
59 }
60
61 /**
62 * Resolve bundled loop instance task starter directory (Netlify Lambda cwd-safe).
63 *
64 * @param {string | URL} [moduleUrl]
65 * @returns {string}
66 */
67 export function resolveStarterLoopInstancesDir(moduleUrl) {
68 if (moduleUrl) {
69 const base = path.dirname(fileURLToPath(moduleUrl));
70 return path.normalize(path.join(base, ...STARTER_INSTANCES_REL_FROM_MODULE));
71 }
72 return path.join(getRepoRoot(), STARTER_LOOP_INSTANCES_DIRNAME);
73 }
74
75 export const MAX_TASK_LOOP_SUMMARIES = 200;
76
77 export const LOOP_ID_RE = /^loop_[a-z0-9_]{1,48}$/;
78 export const OCCURRENCE_KEY_RE = /^[A-Za-z0-9._:-]{1,64}$/;
79 export const GRAPH_ID_RE = /^graph_[a-z0-9_]{1,48}$/;
80
81 export const LOOP_STATUSES = /** @type {const} */ (['active', 'paused', 'cancelled']);
82
83 export const BOUNDARY_POLICIES = /** @type {const} */ ([
84 'observe_only',
85 'draft_only',
86 'propose_only',
87 'execute_with_consent',
88 ]);
89
90 export const COMPOSITION_TIERS = /** @type {const} */ (['personal_safe']);
91
92 export const RECURRENCE_KINDS = /** @type {const} */ ([
93 'cron',
94 'interval',
95 'manual',
96 'on_wake',
97 'rrule',
98 ]);
99
100 export const HANDOFF_TRIGGERS = /** @type {const} */ (['on_pass', 'on_trigger', 'on_conflict']);
101
102 /** @typedef {'personal'|'project'|'org'} TaskLoopScope */
103
104 /**
105 * @typedef {Object} StoredTaskLoop
106 * @property {'knowtation.task_loop/v0'} schema
107 * @property {string} loop_id
108 * @property {typeof TASK_KINDS[number]} kind
109 * @property {TaskLoopScope} scope
110 * @property {typeof LOOP_STATUSES[number]} status
111 * @property {string} title
112 * @property {string} workspace_id
113 * @property {object} recurrence
114 * @property {string} timezone
115 * @property {string|null} [flow_id]
116 * @property {typeof BOUNDARY_POLICIES[number]} boundary_policy
117 * @property {{ kind: string, ref: string }[]} memory_links
118 * @property {string[]} [handoff_refs]
119 * @property {string|null} [until_at]
120 * @property {string} created
121 * @property {string} updated
122 * @property {boolean} truncated
123 */
124
125 /**
126 * @typedef {Object} StoredOrchestratorGraph
127 * @property {'knowtation.orchestrator_graph/v0'} schema
128 * @property {string} graph_id
129 * @property {TaskLoopScope} scope
130 * @property {string} workspace_id
131 * @property {string} title
132 * @property {string[]} nodes
133 * @property {{ from: string, to: string, trigger: typeof HANDOFF_TRIGGERS[number] }[]} edges
134 * @property {typeof COMPOSITION_TIERS[number]} composition_tier
135 * @property {string} created
136 * @property {string} updated
137 */
138
139 /**
140 * @param {unknown} scope
141 * @returns {scope is TaskLoopScope}
142 */
143 function isTaskLoopScope(scope) {
144 return scope === 'personal' || scope === 'project' || scope === 'org';
145 }
146
147 /**
148 * @param {unknown} value
149 * @returns {value is string}
150 */
151 function isIso8601(value) {
152 return typeof value === 'string' && value.trim().length > 0 && !Number.isNaN(Date.parse(value));
153 }
154
155 /**
156 * @param {unknown} raw
157 * @param {string} [seriesTimezone]
158 * @returns {{ ok: true, recurrence: object } | { ok: false, reason: string }}
159 */
160 export function validateRecurrence(raw, seriesTimezone) {
161 if (!raw || typeof raw !== 'object') {
162 return { ok: false, reason: 'recurrence must be an object' };
163 }
164 const recurrence = /** @type {Record<string, unknown>} */ (raw);
165 const kind = recurrence.kind;
166 if (typeof kind !== 'string' || !RECURRENCE_KINDS.includes(/** @type {typeof RECURRENCE_KINDS[number]} */ (kind))) {
167 return { ok: false, reason: 'invalid recurrence.kind' };
168 }
169
170 if (kind === 'cron') {
171 if (typeof recurrence.expression !== 'string' || !recurrence.expression.trim()) {
172 return { ok: false, reason: 'cron recurrence requires expression' };
173 }
174 if (typeof recurrence.anchor_tz !== 'string' || !recurrence.anchor_tz.trim()) {
175 return { ok: false, reason: 'cron recurrence requires anchor_tz' };
176 }
177 return {
178 ok: true,
179 recurrence: {
180 kind: 'cron',
181 expression: recurrence.expression,
182 anchor_tz: recurrence.anchor_tz,
183 },
184 };
185 }
186
187 if (kind === 'interval') {
188 if (typeof recurrence.every !== 'number' || !Number.isInteger(recurrence.every) || recurrence.every < 1) {
189 return { ok: false, reason: 'interval recurrence requires positive integer every' };
190 }
191 const unit = recurrence.unit;
192 if (unit !== 'day' && unit !== 'week' && unit !== 'month') {
193 return { ok: false, reason: 'interval unit must be day|week|month' };
194 }
195 if (!isIso8601(recurrence.anchor_at)) {
196 return { ok: false, reason: 'interval recurrence requires anchor_at ISO8601' };
197 }
198 return {
199 ok: true,
200 recurrence: {
201 kind: 'interval',
202 every: recurrence.every,
203 unit,
204 anchor_at: String(recurrence.anchor_at),
205 },
206 };
207 }
208
209 if (kind === 'manual') {
210 return { ok: true, recurrence: { kind: 'manual' } };
211 }
212
213 if (kind === 'on_wake') {
214 const sourceLoopRef = recurrence.source_loop_ref;
215 if (typeof sourceLoopRef !== 'string' || !LOOP_ID_RE.test(sourceLoopRef)) {
216 return { ok: false, reason: 'on_wake recurrence requires valid source_loop_ref' };
217 }
218 return {
219 ok: true,
220 recurrence: { kind: 'on_wake', source_loop_ref: sourceLoopRef },
221 };
222 }
223
224 if (kind === 'rrule') {
225 if (typeof seriesTimezone !== 'string' || !seriesTimezone.trim()) {
226 return { ok: false, reason: 'rrule recurrence requires series timezone context' };
227 }
228 const rruleResult = validateTaskLoopRrule(recurrence, seriesTimezone);
229 if (!rruleResult.ok) {
230 return { ok: false, reason: `invalid rrule: ${rruleResult.detail}` };
231 }
232 return { ok: true, recurrence: rruleResult.recurrence };
233 }
234
235 return { ok: false, reason: 'unsupported recurrence kind' };
236 }
237
238 /**
239 * @param {unknown} raw
240 * @returns {{ ok: true, loop: StoredTaskLoop } | { ok: false, reason: string }}
241 */
242 export function validateTaskLoopRecord(raw) {
243 if (!raw || typeof raw !== 'object') {
244 return { ok: false, reason: 'task_loop must be an object' };
245 }
246 const loop = /** @type {Record<string, unknown>} */ (raw);
247
248 if (loop.schema !== 'knowtation.task_loop/v0') {
249 return { ok: false, reason: 'schema must be knowtation.task_loop/v0' };
250 }
251 if (typeof loop.loop_id !== 'string' || !LOOP_ID_RE.test(loop.loop_id)) {
252 return { ok: false, reason: 'invalid loop_id' };
253 }
254 if (!TASK_KINDS.includes(/** @type {typeof TASK_KINDS[number]} */ (loop.kind))) {
255 return { ok: false, reason: 'invalid kind' };
256 }
257 if (!isTaskLoopScope(loop.scope)) {
258 return { ok: false, reason: 'scope must be personal|project|org' };
259 }
260 if (!LOOP_STATUSES.includes(/** @type {typeof LOOP_STATUSES[number]} */ (loop.status))) {
261 return { ok: false, reason: 'invalid status' };
262 }
263 if (typeof loop.title !== 'string' || !loop.title.trim()) {
264 return { ok: false, reason: 'title is required' };
265 }
266 if (typeof loop.workspace_id !== 'string' || !loop.workspace_id.trim()) {
267 return { ok: false, reason: 'workspace_id is required' };
268 }
269
270 if (typeof loop.timezone !== 'string' || !loop.timezone.trim()) {
271 return { ok: false, reason: 'timezone is required' };
272 }
273
274 const recurrenceResult = validateRecurrence(loop.recurrence, String(loop.timezone));
275 if (!recurrenceResult.ok) {
276 return recurrenceResult;
277 }
278
279 if (loop.flow_id != null && typeof loop.flow_id !== 'string') {
280 return { ok: false, reason: 'flow_id must be string or null' };
281 }
282 if (
283 !BOUNDARY_POLICIES.includes(
284 /** @type {typeof BOUNDARY_POLICIES[number]} */ (loop.boundary_policy),
285 )
286 ) {
287 return { ok: false, reason: 'invalid boundary_policy' };
288 }
289 if (!Array.isArray(loop.memory_links)) {
290 return { ok: false, reason: 'memory_links must be an array' };
291 }
292 /** @type {{ kind: string, ref: string }[]} */
293 const memoryLinks = [];
294 for (const link of loop.memory_links) {
295 if (!link || typeof link !== 'object') {
296 return { ok: false, reason: 'each memory_link must be an object' };
297 }
298 const row = /** @type {Record<string, unknown>} */ (link);
299 if (typeof row.kind !== 'string' || !row.kind.trim()) {
300 return { ok: false, reason: 'memory_link kind required' };
301 }
302 if (typeof row.ref !== 'string' || !SAFE_ARTIFACT_REF_RE.test(row.ref)) {
303 return { ok: false, reason: 'invalid memory_link ref' };
304 }
305 if (Object.keys(row).some((k) => k !== 'kind' && k !== 'ref')) {
306 return { ok: false, reason: 'memory_links are pointer-only (kind, ref)' };
307 }
308 memoryLinks.push({ kind: row.kind, ref: row.ref });
309 }
310
311 /** @type {string[]} */
312 const handoffRefs = [];
313 if (loop.handoff_refs != null) {
314 if (!Array.isArray(loop.handoff_refs)) {
315 return { ok: false, reason: 'handoff_refs must be an array' };
316 }
317 for (const ref of loop.handoff_refs) {
318 if (typeof ref !== 'string' || !GRAPH_ID_RE.test(ref)) {
319 return { ok: false, reason: 'invalid handoff_refs entry' };
320 }
321 handoffRefs.push(ref);
322 }
323 }
324
325 if (loop.until_at != null && loop.until_at !== null && !isIso8601(loop.until_at)) {
326 return { ok: false, reason: 'until_at must be ISO8601 or null' };
327 }
328 if (!isIso8601(loop.created)) {
329 return { ok: false, reason: 'created must be ISO8601 UTC' };
330 }
331 if (!isIso8601(loop.updated)) {
332 return { ok: false, reason: 'updated must be ISO8601 UTC' };
333 }
334 if (typeof loop.truncated !== 'boolean') {
335 return { ok: false, reason: 'truncated must be boolean' };
336 }
337
338 /** @type {object[]|undefined} */
339 let triggerBindings;
340 if (loop.trigger_bindings != null) {
341 if (!Array.isArray(loop.trigger_bindings)) {
342 return { ok: false, reason: 'trigger_bindings must be an array' };
343 }
344 triggerBindings = [];
345 for (const bindingRaw of loop.trigger_bindings) {
346 if (!bindingRaw || typeof bindingRaw !== 'object') {
347 return { ok: false, reason: 'each trigger_binding must be an object' };
348 }
349 const binding = /** @type {Record<string, unknown>} */ (bindingRaw);
350 if (binding.schema !== 'knowtation.loop_trigger_binding/v0') {
351 return { ok: false, reason: 'invalid trigger_binding schema' };
352 }
353 if (typeof binding.binding_id !== 'string' || !/^bind_[a-z0-9_]{1,48}$/.test(binding.binding_id)) {
354 return { ok: false, reason: 'invalid binding_id' };
355 }
356 const signalKind = binding.signal_kind;
357 if (
358 signalKind !== 'manual' &&
359 signalKind !== 'task_status' &&
360 signalKind !== 'calendar_timeline' &&
361 signalKind !== 'note_edit'
362 ) {
363 return { ok: false, reason: 'invalid signal_kind' };
364 }
365 if (typeof binding.debounce_seconds !== 'number' || binding.debounce_seconds < 1 || binding.debounce_seconds > 86400) {
366 return { ok: false, reason: 'invalid debounce_seconds' };
367 }
368 if (typeof binding.enabled !== 'boolean') {
369 return { ok: false, reason: 'enabled must be boolean' };
370 }
371 const match = binding.match;
372 if (!match || typeof match !== 'object') {
373 return { ok: false, reason: 'match required on trigger_binding' };
374 }
375 const matchRow = /** @type {Record<string, unknown>} */ (match);
376 const matchKind = matchRow.kind;
377 if (matchKind === 'note_path' && typeof matchRow.path === 'string') {
378 if (matchRow.path.includes('..') || matchRow.path.startsWith('/')) {
379 return { ok: false, reason: 'invalid note_path in trigger_binding' };
380 }
381 } else if (matchKind === 'folder_prefix' && typeof matchRow.prefix === 'string') {
382 if (matchRow.prefix.includes('..') || matchRow.prefix.startsWith('/')) {
383 return { ok: false, reason: 'invalid folder_prefix in trigger_binding' };
384 }
385 }
386 triggerBindings.push({
387 schema: 'knowtation.loop_trigger_binding/v0',
388 binding_id: binding.binding_id,
389 signal_kind: signalKind,
390 match,
391 debounce_seconds: binding.debounce_seconds,
392 enabled: binding.enabled,
393 });
394 }
395 }
396
397 return {
398 ok: true,
399 loop: {
400 schema: 'knowtation.task_loop/v0',
401 loop_id: loop.loop_id,
402 kind: /** @type {typeof TASK_KINDS[number]} */ (loop.kind),
403 scope: loop.scope,
404 status: /** @type {typeof LOOP_STATUSES[number]} */ (loop.status),
405 title: loop.title,
406 workspace_id: loop.workspace_id,
407 recurrence: recurrenceResult.recurrence,
408 timezone: loop.timezone,
409 flow_id: loop.flow_id == null ? null : String(loop.flow_id),
410 boundary_policy: /** @type {typeof BOUNDARY_POLICIES[number]} */ (loop.boundary_policy),
411 memory_links: memoryLinks,
412 handoff_refs: handoffRefs.length > 0 ? handoffRefs : undefined,
413 trigger_bindings: triggerBindings,
414 until_at: loop.until_at == null ? null : String(loop.until_at),
415 created: String(loop.created),
416 updated: String(loop.updated),
417 truncated: loop.truncated,
418 },
419 };
420 }
421
422 /**
423 * @param {unknown} raw
424 * @returns {{ ok: true, graph: StoredOrchestratorGraph } | { ok: false, reason: string }}
425 */
426 export function validateOrchestratorGraphRecord(raw) {
427 if (!raw || typeof raw !== 'object') {
428 return { ok: false, reason: 'orchestrator_graph must be an object' };
429 }
430 const graph = /** @type {Record<string, unknown>} */ (raw);
431
432 if (graph.schema !== 'knowtation.orchestrator_graph/v0') {
433 return { ok: false, reason: 'schema must be knowtation.orchestrator_graph/v0' };
434 }
435 if (typeof graph.graph_id !== 'string' || !GRAPH_ID_RE.test(graph.graph_id)) {
436 return { ok: false, reason: 'invalid graph_id' };
437 }
438 if (!isTaskLoopScope(graph.scope)) {
439 return { ok: false, reason: 'scope must be personal|project|org' };
440 }
441 if (typeof graph.workspace_id !== 'string' || !graph.workspace_id.trim()) {
442 return { ok: false, reason: 'workspace_id is required' };
443 }
444 if (typeof graph.title !== 'string' || !graph.title.trim()) {
445 return { ok: false, reason: 'title is required' };
446 }
447 if (!Array.isArray(graph.nodes) || graph.nodes.length === 0) {
448 return { ok: false, reason: 'nodes must be a non-empty array' };
449 }
450 for (const node of graph.nodes) {
451 if (typeof node !== 'string' || !LOOP_ID_RE.test(node)) {
452 return { ok: false, reason: 'each node must be a valid loop_id' };
453 }
454 }
455 if (!Array.isArray(graph.edges)) {
456 return { ok: false, reason: 'edges must be an array' };
457 }
458 /** @type {{ from: string, to: string, trigger: typeof HANDOFF_TRIGGERS[number] }[]} */
459 const edges = [];
460 for (const edge of graph.edges) {
461 if (!edge || typeof edge !== 'object') {
462 return { ok: false, reason: 'each edge must be an object' };
463 }
464 const row = /** @type {Record<string, unknown>} */ (edge);
465 if (typeof row.from !== 'string' || !LOOP_ID_RE.test(row.from)) {
466 return { ok: false, reason: 'edge.from must be valid loop_id' };
467 }
468 if (typeof row.to !== 'string' || !LOOP_ID_RE.test(row.to)) {
469 return { ok: false, reason: 'edge.to must be valid loop_id' };
470 }
471 if (
472 !HANDOFF_TRIGGERS.includes(
473 /** @type {typeof HANDOFF_TRIGGERS[number]} */ (row.trigger),
474 )
475 ) {
476 return { ok: false, reason: 'invalid edge.trigger' };
477 }
478 edges.push({
479 from: row.from,
480 to: row.to,
481 trigger: /** @type {typeof HANDOFF_TRIGGERS[number]} */ (row.trigger),
482 });
483 }
484 if (
485 !COMPOSITION_TIERS.includes(
486 /** @type {typeof COMPOSITION_TIERS[number]} */ (graph.composition_tier),
487 )
488 ) {
489 return { ok: false, reason: 'invalid composition_tier' };
490 }
491 if (!isIso8601(graph.created)) {
492 return { ok: false, reason: 'created must be ISO8601 UTC' };
493 }
494 if (!isIso8601(graph.updated)) {
495 return { ok: false, reason: 'updated must be ISO8601 UTC' };
496 }
497
498 return {
499 ok: true,
500 graph: {
501 schema: 'knowtation.orchestrator_graph/v0',
502 graph_id: graph.graph_id,
503 scope: graph.scope,
504 workspace_id: graph.workspace_id,
505 title: graph.title,
506 nodes: [...graph.nodes],
507 edges,
508 composition_tier: /** @type {typeof COMPOSITION_TIERS[number]} */ (graph.composition_tier),
509 created: String(graph.created),
510 updated: String(graph.updated),
511 },
512 };
513 }
514
515 /**
516 * @param {StoredTaskLoop} loop
517 * @returns {object}
518 */
519 export function taskLoopSummaryForClient(loop) {
520 return {
521 schema: 'knowtation.task_loop/v0',
522 loop_id: loop.loop_id,
523 kind: loop.kind,
524 scope: loop.scope,
525 status: loop.status,
526 title: loop.title,
527 workspace_id: loop.workspace_id,
528 recurrence_kind: loop.recurrence.kind,
529 boundary_policy: loop.boundary_policy,
530 truncated: loop.truncated,
531 };
532 }
533
534 /**
535 * @param {StoredTaskLoop} loop
536 * @returns {StoredTaskLoop}
537 */
538 export function taskLoopForClient(loop) {
539 return { ...loop };
540 }
541
542 /**
543 * @param {StoredOrchestratorGraph} graph
544 * @returns {StoredOrchestratorGraph}
545 */
546 export function orchestratorGraphForClient(graph) {
547 return { ...graph };
548 }
549
550 /**
551 * Ensure vault bucket arrays exist for loop store extensions.
552 *
553 * @param {import('../flow/flow-store.mjs').VaultFlowStore} vault
554 */
555 function ensureLoopVaultBuckets(vault) {
556 if (!Array.isArray(vault.task_loops)) {
557 vault.task_loops = [];
558 }
559 if (!Array.isArray(vault.orchestrator_graphs)) {
560 vault.orchestrator_graphs = [];
561 }
562 }
563
564 /**
565 * @param {import('../flow/flow-store.mjs').FlowStoreFile} store
566 * @param {string} vaultId
567 * @returns {import('../flow/flow-store.mjs').VaultFlowStore}
568 */
569 function ensureVaultInStore(store, vaultId) {
570 if (!store.vaults[vaultId]) {
571 store.vaults[vaultId] = {
572 flows: [],
573 steps: [],
574 runs: [],
575 candidates: [],
576 projections: [],
577 tasks: [],
578 task_loops: [],
579 orchestrator_graphs: [],
580 };
581 } else {
582 ensureLoopVaultBuckets(store.vaults[vaultId]);
583 if (!Array.isArray(store.vaults[vaultId].tasks)) {
584 store.vaults[vaultId].tasks = [];
585 }
586 }
587 return store.vaults[vaultId];
588 }
589
590 /**
591 * @param {string} dataDir
592 * @param {string} vaultId
593 * @returns {import('../flow/flow-store.mjs').VaultFlowStore}
594 */
595 function getVaultWithLoopBuckets(dataDir, vaultId) {
596 const store = loadFlowStore(dataDir);
597 return ensureVaultInStore(store, vaultId);
598 }
599
600 /**
601 * @param {string} dataDir
602 * @param {string} vaultId
603 * @param {{ starterDir?: string, onReject?: (name: string, reason: string) => void }} [options]
604 */
605 export function seedStarterTaskLoops(dataDir, vaultId, options = {}) {
606 const starterDir =
607 options.starterDir ?? path.join(getRepoRoot(), STARTER_TASK_LOOPS_DIRNAME);
608 const onReject = options.onReject ?? ((name, reason) => {
609 console.warn(`[task-loop-store] rejected loop ${name}: ${reason}`);
610 });
611
612 if (!fs.existsSync(starterDir)) {
613 return { seeded: 0, skipped: 0 };
614 }
615
616 const store = loadFlowStore(dataDir);
617 const vault = ensureVaultInStore(store, vaultId);
618
619 let seeded = 0;
620 let skipped = 0;
621
622 const files = fs
623 .readdirSync(starterDir)
624 .filter((f) => f.startsWith('loop_') && f.endsWith('.json'))
625 .sort();
626
627 for (const file of files) {
628 let raw;
629 try {
630 raw = JSON.parse(fs.readFileSync(path.join(starterDir, file), 'utf8'));
631 } catch {
632 onReject(file, 'invalid JSON');
633 continue;
634 }
635
636 const validated = validateTaskLoopRecord(raw);
637 if (!validated.ok) {
638 onReject(file, validated.reason);
639 continue;
640 }
641
642 const exists = vault.task_loops.some((l) => l.loop_id === validated.loop.loop_id);
643 if (exists) {
644 skipped += 1;
645 continue;
646 }
647
648 vault.task_loops.push(validated.loop);
649 seeded += 1;
650 }
651
652 if (seeded > 0) {
653 saveFlowStore(dataDir, store);
654 }
655
656 return { seeded, skipped };
657 }
658
659 /**
660 * @param {string} dataDir
661 * @param {string} vaultId
662 * @param {{ starterDir?: string, onReject?: (name: string, reason: string) => void }} [options]
663 */
664 export function seedStarterOrchestratorGraphs(dataDir, vaultId, options = {}) {
665 const starterDir =
666 options.starterDir ?? path.join(getRepoRoot(), STARTER_ORCHESTRATOR_GRAPHS_DIRNAME);
667 const onReject = options.onReject ?? ((name, reason) => {
668 console.warn(`[task-loop-store] rejected graph ${name}: ${reason}`);
669 });
670
671 if (!fs.existsSync(starterDir)) {
672 return { seeded: 0, skipped: 0 };
673 }
674
675 const store = loadFlowStore(dataDir);
676 const vault = ensureVaultInStore(store, vaultId);
677
678 let seeded = 0;
679 let skipped = 0;
680
681 const files = fs
682 .readdirSync(starterDir)
683 .filter((f) => f.startsWith('graph_') && f.endsWith('.json'))
684 .sort();
685
686 for (const file of files) {
687 let raw;
688 try {
689 raw = JSON.parse(fs.readFileSync(path.join(starterDir, file), 'utf8'));
690 } catch {
691 onReject(file, 'invalid JSON');
692 continue;
693 }
694
695 const validated = validateOrchestratorGraphRecord(raw);
696 if (!validated.ok) {
697 onReject(file, validated.reason);
698 continue;
699 }
700
701 const exists = vault.orchestrator_graphs.some((g) => g.graph_id === validated.graph.graph_id);
702 if (exists) {
703 skipped += 1;
704 continue;
705 }
706
707 vault.orchestrator_graphs.push(validated.graph);
708 seeded += 1;
709 }
710
711 if (seeded > 0) {
712 saveFlowStore(dataDir, store);
713 }
714
715 return { seeded, skipped };
716 }
717
718 /**
719 * Seed loop instance tasks from task-loops/starter/instances/.
720 *
721 * @param {string} dataDir
722 * @param {string} vaultId
723 * @param {{ instancesDir?: string, onReject?: (name: string, reason: string) => void }} [options]
724 */
725 export function seedStarterLoopInstances(dataDir, vaultId, options = {}) {
726 const instancesDir =
727 options.instancesDir ?? path.join(getRepoRoot(), STARTER_LOOP_INSTANCES_DIRNAME);
728 const onReject = options.onReject ?? ((name, reason) => {
729 console.warn(`[task-loop-store] rejected instance ${name}: ${reason}`);
730 });
731
732 if (!fs.existsSync(instancesDir)) {
733 return { seeded: 0, skipped: 0 };
734 }
735
736 const store = loadFlowStore(dataDir);
737 const vault = ensureVaultInStore(store, vaultId);
738
739 let seeded = 0;
740 let skipped = 0;
741
742 const files = fs
743 .readdirSync(instancesDir)
744 .filter((f) => f.startsWith('task_') && f.endsWith('.json'))
745 .sort();
746
747 for (const file of files) {
748 let raw;
749 try {
750 raw = JSON.parse(fs.readFileSync(path.join(instancesDir, file), 'utf8'));
751 } catch {
752 onReject(file, 'invalid JSON');
753 continue;
754 }
755
756 const validated = validateTaskRecord(raw);
757 if (!validated.ok) {
758 onReject(file, validated.reason);
759 continue;
760 }
761
762 const { task } = validated;
763 const exists = vault.tasks.some((t) => t.task_id === task.task_id);
764 if (exists) {
765 skipped += 1;
766 continue;
767 }
768
769 vault.tasks.push(task);
770 seeded += 1;
771 }
772
773 if (seeded > 0) {
774 saveFlowStore(dataDir, store);
775 }
776
777 return { seeded, skipped };
778 }
779
780 /**
781 * @param {string} dataDir
782 * @param {string} vaultId
783 * @param {{ starterDir?: string, instancesDir?: string, graphsDir?: string }} [options]
784 */
785 function ensureLoopStarterSeed(dataDir, vaultId, options = {}) {
786 const vault = getVaultWithLoopBuckets(dataDir, vaultId);
787 if (vault.task_loops.length > 0) return;
788
789 seedStarterTaskLoops(dataDir, vaultId, { starterDir: options.starterDir });
790 seedStarterOrchestratorGraphs(dataDir, vaultId, { starterDir: options.graphsDir });
791 seedStarterLoopInstances(dataDir, vaultId, { instancesDir: options.instancesDir });
792 }
793
794 /**
795 * Compare loops for list ordering: loop_id asc.
796 *
797 * @param {StoredTaskLoop} a
798 * @param {StoredTaskLoop} b
799 */
800 function compareTaskLoopsForList(a, b) {
801 return a.loop_id.localeCompare(b.loop_id);
802 }
803
804 /**
805 * @param {string} dataDir
806 * @param {string} vaultId
807 * @param {{
808 * visibleScopes?: Set<TaskLoopScope>,
809 * filterScopes?: Set<TaskLoopScope>,
810 * effectiveScope: TaskLoopScope,
811 * workspaceId?: string,
812 * status?: string,
813 * kind?: string,
814 * limit?: number,
815 * starterDir?: string,
816 * graphsDir?: string,
817 * instancesDir?: string,
818 * }} query
819 */
820 export function listTaskLoops(dataDir, vaultId, query) {
821 ensureLoopStarterSeed(dataDir, vaultId, {
822 starterDir: query.starterDir,
823 graphsDir: query.graphsDir,
824 instancesDir: query.instancesDir,
825 });
826
827 const filterScopes = query.filterScopes ?? query.visibleScopes ?? new Set(['personal']);
828 const workspaceId =
829 typeof query.workspaceId === 'string' && query.workspaceId.trim()
830 ? query.workspaceId.trim()
831 : '';
832 const statusFilter =
833 typeof query.status === 'string' && query.status.trim() ? query.status.trim() : '';
834 const kindFilter = typeof query.kind === 'string' && query.kind.trim() ? query.kind.trim() : '';
835
836 let limit =
837 typeof query.limit === 'number' ? query.limit : MAX_TASK_LOOP_SUMMARIES;
838 if (!Number.isInteger(limit) || limit < 1) limit = MAX_TASK_LOOP_SUMMARIES;
839 if (limit > MAX_TASK_LOOP_SUMMARIES) limit = MAX_TASK_LOOP_SUMMARIES;
840
841 const store = loadFlowStore(dataDir);
842 const vault = store.vaults[vaultId] ?? { task_loops: [] };
843 const loops = vault.task_loops ?? [];
844
845 let candidates = loops.filter((loop) => {
846 if (!filterScopes.has(loop.scope)) return false;
847 if (workspaceId && loop.workspace_id !== workspaceId) return false;
848 if (statusFilter && loop.status !== statusFilter) return false;
849 if (kindFilter && loop.kind !== kindFilter) return false;
850 return true;
851 });
852
853 candidates.sort(compareTaskLoopsForList);
854
855 const totalMatching = candidates.length;
856 let truncated = totalMatching > limit;
857 if (candidates.length > limit) {
858 candidates = candidates.slice(0, limit);
859 }
860
861 return {
862 schema: 'knowtation.task_loop_list/v0',
863 vault_id: vaultId,
864 effective_scope: query.effectiveScope,
865 loops: candidates.map((loop) => taskLoopSummaryForClient(loop)),
866 truncated,
867 };
868 }
869
870 /**
871 * @param {string} dataDir
872 * @param {string} vaultId
873 * @param {string} loopId
874 * @param {{ visibleScopes?: Set<TaskLoopScope>, starterDir?: string, graphsDir?: string, instancesDir?: string }} query
875 * @returns {StoredTaskLoop|null}
876 */
877 export function getTaskLoop(dataDir, vaultId, loopId, query) {
878 if (!LOOP_ID_RE.test(loopId)) {
879 return null;
880 }
881
882 ensureLoopStarterSeed(dataDir, vaultId, {
883 starterDir: query.starterDir,
884 graphsDir: query.graphsDir,
885 instancesDir: query.instancesDir,
886 });
887
888 const filterScopes = query.visibleScopes ?? new Set(['personal']);
889 const store = loadFlowStore(dataDir);
890 const vault = store.vaults[vaultId];
891 if (!vault) return null;
892
893 const row = (vault.task_loops ?? []).find((l) => l.loop_id === loopId);
894 if (!row) return null;
895 if (!filterScopes.has(row.scope)) return null;
896 return row;
897 }
898
899 /**
900 * @param {string} dataDir
901 * @param {string} vaultId
902 * @param {string} graphId
903 * @param {{ visibleScopes?: Set<TaskLoopScope>, starterDir?: string, graphsDir?: string, instancesDir?: string }} query
904 * @returns {StoredOrchestratorGraph|null}
905 */
906 export function getOrchestratorGraph(dataDir, vaultId, graphId, query) {
907 if (!GRAPH_ID_RE.test(graphId)) {
908 return null;
909 }
910
911 ensureLoopStarterSeed(dataDir, vaultId, {
912 starterDir: query.starterDir,
913 graphsDir: query.graphsDir,
914 instancesDir: query.instancesDir,
915 });
916
917 const filterScopes = query.visibleScopes ?? new Set(['personal']);
918 const store = loadFlowStore(dataDir);
919 const vault = store.vaults[vaultId];
920 if (!vault) return null;
921
922 const row = (vault.orchestrator_graphs ?? []).find((g) => g.graph_id === graphId);
923 if (!row) return null;
924 if (!filterScopes.has(row.scope)) return null;
925 return row;
926 }
927
928 /**
929 * @param {Set<TaskLoopScope>} visibleScopes
930 * @returns {TaskLoopScope}
931 */
932 export function taskLoopGetEffectiveScope(visibleScopes) {
933 return highestFlowScope(visibleScopes);
934 }
935
936 /**
937 * Assert (loop_ref, occurrence_key) uniqueness within a vault — data-integrity helper.
938 *
939 * @param {string} dataDir
940 * @param {string} vaultId
941 * @returns {{ ok: true } | { ok: false, duplicate: string }}
942 */
943 export function assertLoopOccurrenceUniqueness(dataDir, vaultId) {
944 const store = loadFlowStore(dataDir);
945 const vault = store.vaults[vaultId];
946 if (!vault) return { ok: true };
947
948 /** @type {Map<string, string>} */
949 const seen = new Map();
950 for (const task of vault.tasks ?? []) {
951 const loopRef = task.loop_ref;
952 const occurrenceKey = task.occurrence_key;
953 if (loopRef == null || occurrenceKey == null) continue;
954 const key = `${loopRef}::${occurrenceKey}`;
955 if (seen.has(key)) {
956 return { ok: false, duplicate: key };
957 }
958 seen.set(key, task.task_id);
959 }
960 return { ok: true };
961 }
962
963 export { UID_HASH_REF_RE };
File History 1 commit
sha256:baa800aedf841dbf32081aa7b2befa288ac33dfc7175ac55014c55c4d8c742f9 docs: move durable-auth freeze/evidence to local developmen… Human 11 days ago