task-loop-store.mjs
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