task-store.mjs
572 lines 19.0 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days ago
1 /**
2 * Local file-backed Task store (Phase 2G-b — Option A calendar/flow parity).
3 *
4 * Tasks persist in the shared `hub_flow_store.json` vault bucket (`tasks[]`).
5 * Read-only list/get in v0; idempotent starter seed on first read.
6 *
7 * @see docs/TASK-STORE-CONTRACT-2G.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 {
15 loadFlowStore,
16 saveFlowStore,
17 FLOW_RUN_ID_RE,
18 seedOverseerAnchorRun,
19 } from '../flow/flow-store.mjs';
20 import { highestFlowScope } from '../flow/flow-scope.mjs';
21
22 /** Loop instance field regexes — parity with task-loop-store.mjs (avoid circular import). */
23 const LOOP_ID_RE = /^loop_[a-z0-9_]{1,48}$/;
24 const OCCURRENCE_KEY_RE = /^[A-Za-z0-9._:-]{1,64}$/;
25 const LOOP_STATUSES = /** @type {const} */ (['active', 'paused', 'cancelled']);
26
27 export const STARTER_TASKS_DIRNAME = 'tasks/starter';
28
29 /** Parent segments from any package module to bundled `tasks/starter` (hub/bridge, lib/task, …). */
30 const STARTER_REL_FROM_MODULE = ['..', '..', 'tasks', 'starter'];
31
32 /**
33 * Resolve bundled starter task JSON directory (Netlify Lambda cwd-safe).
34 *
35 * @param {string | URL} [moduleUrl] `import.meta.url` of a module in this package
36 * @returns {string}
37 */
38 export function resolveStarterTasksDir(moduleUrl) {
39 if (moduleUrl) {
40 const base = path.dirname(fileURLToPath(moduleUrl));
41 return path.normalize(path.join(base, ...STARTER_REL_FROM_MODULE));
42 }
43 return path.join(getRepoRoot(), STARTER_TASKS_DIRNAME);
44 }
45 export const MAX_TASK_SUMMARIES = 500;
46 export const MAX_ARTIFACT_LINKS = 32;
47
48 export const TASK_ID_RE = /^task_[a-z0-9_]{1,48}$/;
49 export const UID_HASH_REF_RE = /^uid_hash:[a-f0-9]{64}$/;
50 /** Agent queue assignee (Phase 7D external protocol). */
51 export const AGENT_ASSIGNEE_REF_RE = /^agent_[a-z0-9_]{1,64}$/;
52 export const SAFE_ARTIFACT_REF_RE = /^[A-Za-z0-9._:#-]{1,256}$/;
53
54 /**
55 * @param {unknown} ref
56 * @returns {boolean}
57 */
58 export function isValidTaskAssigneeRef(ref) {
59 if (ref == null) return true;
60 if (typeof ref !== 'string') return false;
61 if (ref === '*') return true;
62 return UID_HASH_REF_RE.test(ref) || AGENT_ASSIGNEE_REF_RE.test(ref);
63 }
64
65 export const TASK_KINDS = /** @type {const} */ ([
66 'personal',
67 'assignment',
68 'mentor_checkin',
69 'org_work_job',
70 ]);
71
72 export const TASK_STATUSES = /** @type {const} */ ([
73 'pending',
74 'in_progress',
75 'blocked',
76 'done',
77 'cancelled',
78 ]);
79
80 /** @type {readonly ['observe_only', 'draft_only', 'propose_only', 'execute_with_consent']} */
81 export const BOUNDARY_POLICIES = /** @type {const} */ ([
82 'observe_only',
83 'draft_only',
84 'propose_only',
85 'execute_with_consent',
86 ]);
87
88 export const ARTIFACT_LINK_KINDS = /** @type {const} */ (['note', 'media', 'review_item']);
89
90 /** @typedef {'personal'|'project'|'org'} TaskScope */
91
92 /**
93 * @typedef {Object} StoredTask
94 * @property {'knowtation.task/v0'} schema
95 * @property {string} task_id
96 * @property {typeof TASK_KINDS[number]} kind
97 * @property {TaskScope} scope
98 * @property {typeof TASK_STATUSES[number]} status
99 * @property {string} title
100 * @property {string} workspace_id
101 * @property {string|null} due_at
102 * @property {string|null} [assignee_ref]
103 * @property {string|null} [assigner_ref]
104 * @property {string|null} [run_ref]
105 * @property {string|null} [loop_ref]
106 * @property {string|null} [occurrence_key]
107 * @property {string|null} [occurrence_at]
108 * @property {typeof LOOP_STATUSES[number]|null} [series_status_snapshot]
109 * @property {string|null} [skip_reason]
110 * @property {{ kind: string, ref: string }[]} artifact_links
111 * @property {string} created
112 * @property {string} updated
113 * @property {boolean} truncated
114 * @property {string|null} [external_pass_id]
115 * @property {string|null} [external_lease_expires_at]
116 * @property {string|null} [external_active_grant_id]
117 * @property {typeof BOUNDARY_POLICIES[number]} [boundary_policy]
118 */
119
120 /**
121 * @param {unknown} scope
122 * @returns {scope is TaskScope}
123 */
124 function isTaskScope(scope) {
125 return scope === 'personal' || scope === 'project' || scope === 'org';
126 }
127
128 /**
129 * @param {unknown} value
130 * @returns {value is string}
131 */
132 function isIso8601(value) {
133 return typeof value === 'string' && value.trim().length > 0 && !Number.isNaN(Date.parse(value));
134 }
135
136 /**
137 * Validate a starter task record against §1 schema.
138 *
139 * @param {unknown} raw
140 * @returns {{ ok: true, task: StoredTask } | { ok: false, reason: string }}
141 */
142 export function validateTaskRecord(raw) {
143 if (!raw || typeof raw !== 'object') {
144 return { ok: false, reason: 'task must be an object' };
145 }
146 const task = /** @type {Record<string, unknown>} */ (raw);
147
148 if (task.schema !== 'knowtation.task/v0') {
149 return { ok: false, reason: 'schema must be knowtation.task/v0' };
150 }
151 if (typeof task.task_id !== 'string' || !TASK_ID_RE.test(task.task_id)) {
152 return { ok: false, reason: 'invalid task_id' };
153 }
154 if (!TASK_KINDS.includes(/** @type {typeof TASK_KINDS[number]} */ (task.kind))) {
155 return { ok: false, reason: 'invalid kind' };
156 }
157 if (!isTaskScope(task.scope)) {
158 return { ok: false, reason: 'scope must be personal|project|org' };
159 }
160 if (!TASK_STATUSES.includes(/** @type {typeof TASK_STATUSES[number]} */ (task.status))) {
161 return { ok: false, reason: 'invalid status' };
162 }
163 if (typeof task.title !== 'string' || !task.title.trim()) {
164 return { ok: false, reason: 'title is required' };
165 }
166 if (typeof task.workspace_id !== 'string' || !task.workspace_id.trim()) {
167 return { ok: false, reason: 'workspace_id is required' };
168 }
169 if (task.due_at !== null && !isIso8601(task.due_at)) {
170 return { ok: false, reason: 'due_at must be ISO8601 UTC or null' };
171 }
172 if (!isValidTaskAssigneeRef(task.assignee_ref)) {
173 return { ok: false, reason: 'assignee_ref must be uid_hash:<64-hex>, agent_<id>, *, or null' };
174 }
175 if (task.assigner_ref != null && typeof task.assigner_ref !== 'string') {
176 return { ok: false, reason: 'assigner_ref must be string or null' };
177 }
178 if (task.run_ref != null && (typeof task.run_ref !== 'string' || !FLOW_RUN_ID_RE.test(task.run_ref))) {
179 return { ok: false, reason: 'invalid run_ref' };
180 }
181 if (task.loop_ref != null && (typeof task.loop_ref !== 'string' || !LOOP_ID_RE.test(task.loop_ref))) {
182 return { ok: false, reason: 'invalid loop_ref' };
183 }
184 if (
185 task.occurrence_key != null &&
186 (typeof task.occurrence_key !== 'string' || !OCCURRENCE_KEY_RE.test(task.occurrence_key))
187 ) {
188 return { ok: false, reason: 'invalid occurrence_key' };
189 }
190 if (task.occurrence_at != null && task.occurrence_at !== null && !isIso8601(task.occurrence_at)) {
191 return { ok: false, reason: 'occurrence_at must be ISO8601 or null' };
192 }
193 if (
194 task.series_status_snapshot != null &&
195 task.series_status_snapshot !== null &&
196 !LOOP_STATUSES.includes(
197 /** @type {typeof LOOP_STATUSES[number]} */ (task.series_status_snapshot),
198 )
199 ) {
200 return { ok: false, reason: 'invalid series_status_snapshot' };
201 }
202 if (
203 task.skip_reason != null &&
204 task.skip_reason !== null &&
205 (typeof task.skip_reason !== 'string' || task.skip_reason.length > 256)
206 ) {
207 return { ok: false, reason: 'invalid skip_reason' };
208 }
209 if (!Array.isArray(task.artifact_links)) {
210 return { ok: false, reason: 'artifact_links must be an array' };
211 }
212 /** @type {{ kind: string, ref: string }[]} */
213 const artifactLinks = [];
214 for (const link of task.artifact_links) {
215 if (!link || typeof link !== 'object') {
216 return { ok: false, reason: 'each artifact_link must be an object' };
217 }
218 const row = /** @type {Record<string, unknown>} */ (link);
219 if (!ARTIFACT_LINK_KINDS.includes(/** @type {typeof ARTIFACT_LINK_KINDS[number]} */ (row.kind))) {
220 return { ok: false, reason: 'invalid artifact_link kind' };
221 }
222 if (typeof row.ref !== 'string' || !SAFE_ARTIFACT_REF_RE.test(row.ref)) {
223 return { ok: false, reason: 'invalid artifact_link ref' };
224 }
225 if (Object.keys(row).some((k) => k !== 'kind' && k !== 'ref')) {
226 return { ok: false, reason: 'artifact_links are pointer-only (kind, ref)' };
227 }
228 artifactLinks.push({ kind: row.kind, ref: row.ref });
229 }
230 if (!isIso8601(task.created)) {
231 return { ok: false, reason: 'created must be ISO8601 UTC' };
232 }
233 if (!isIso8601(task.updated)) {
234 return { ok: false, reason: 'updated must be ISO8601 UTC' };
235 }
236 if (typeof task.truncated !== 'boolean') {
237 return { ok: false, reason: 'truncated must be boolean' };
238 }
239 if (task.external_pass_id != null && typeof task.external_pass_id !== 'string') {
240 return { ok: false, reason: 'external_pass_id must be string or null' };
241 }
242 if (task.external_lease_expires_at != null && !isIso8601(task.external_lease_expires_at)) {
243 return { ok: false, reason: 'external_lease_expires_at must be ISO8601 UTC or null' };
244 }
245 if (task.external_active_grant_id != null && typeof task.external_active_grant_id !== 'string') {
246 return { ok: false, reason: 'external_active_grant_id must be string or null' };
247 }
248 const boundaryPolicyRaw =
249 task.boundary_policy == null || task.boundary_policy === undefined
250 ? 'observe_only'
251 : task.boundary_policy;
252 if (
253 !BOUNDARY_POLICIES.includes(/** @type {typeof BOUNDARY_POLICIES[number]} */ (boundaryPolicyRaw))
254 ) {
255 return { ok: false, reason: 'invalid boundary_policy' };
256 }
257
258 return {
259 ok: true,
260 task: {
261 schema: 'knowtation.task/v0',
262 task_id: task.task_id,
263 kind: /** @type {typeof TASK_KINDS[number]} */ (task.kind),
264 scope: task.scope,
265 status: /** @type {typeof TASK_STATUSES[number]} */ (task.status),
266 title: task.title,
267 workspace_id: task.workspace_id,
268 due_at: task.due_at === null ? null : String(task.due_at),
269 assignee_ref: task.assignee_ref == null ? null : String(task.assignee_ref),
270 assigner_ref: task.assigner_ref == null ? null : String(task.assigner_ref),
271 run_ref: task.run_ref == null ? null : String(task.run_ref),
272 loop_ref: task.loop_ref == null ? null : String(task.loop_ref),
273 occurrence_key: task.occurrence_key == null ? null : String(task.occurrence_key),
274 occurrence_at: task.occurrence_at == null ? null : String(task.occurrence_at),
275 series_status_snapshot:
276 task.series_status_snapshot == null
277 ? null
278 : /** @type {typeof LOOP_STATUSES[number]} */ (task.series_status_snapshot),
279 skip_reason: task.skip_reason == null ? null : String(task.skip_reason),
280 artifact_links: artifactLinks,
281 created: String(task.created),
282 updated: String(task.updated),
283 truncated: task.truncated,
284 external_pass_id: task.external_pass_id == null ? null : String(task.external_pass_id),
285 external_lease_expires_at: task.external_lease_expires_at == null ? null : String(task.external_lease_expires_at),
286 external_active_grant_id: task.external_active_grant_id == null ? null : String(task.external_active_grant_id),
287 boundary_policy: /** @type {typeof BOUNDARY_POLICIES[number]} */ (boundaryPolicyRaw),
288 },
289 };
290 }
291
292 /**
293 * @param {StoredTask} task
294 * @returns {object}
295 */
296 export function taskSummaryForClient(task) {
297 return {
298 schema: 'knowtation.task/v0',
299 task_id: task.task_id,
300 kind: task.kind,
301 scope: task.scope,
302 status: task.status,
303 title: task.title,
304 workspace_id: task.workspace_id,
305 due_at: task.due_at,
306 run_ref: task.run_ref ?? null,
307 loop_ref: task.loop_ref ?? null,
308 occurrence_key: task.occurrence_key ?? null,
309 truncated: task.truncated,
310 };
311 }
312
313 /**
314 * @param {StoredTask} task
315 * @returns {StoredTask}
316 */
317 export function taskForClient(task) {
318 let links = task.artifact_links ?? [];
319 let truncated = task.truncated;
320 if (links.length > MAX_ARTIFACT_LINKS) {
321 links = links.slice(0, MAX_ARTIFACT_LINKS);
322 truncated = true;
323 }
324 return {
325 schema: task.schema,
326 task_id: task.task_id,
327 kind: task.kind,
328 scope: task.scope,
329 status: task.status,
330 title: task.title,
331 workspace_id: task.workspace_id,
332 due_at: task.due_at,
333 assignee_ref: task.assignee_ref ?? null,
334 assigner_ref: task.assigner_ref ?? null,
335 run_ref: task.run_ref ?? null,
336 loop_ref: task.loop_ref ?? null,
337 occurrence_key: task.occurrence_key ?? null,
338 occurrence_at: task.occurrence_at ?? null,
339 series_status_snapshot: task.series_status_snapshot ?? null,
340 skip_reason: task.skip_reason ?? null,
341 artifact_links: links,
342 created: task.created,
343 updated: task.updated,
344 truncated,
345 };
346 }
347
348 /**
349 * Idempotently seed canonical starter tasks from tasks/starter/.
350 *
351 * @param {string} dataDir
352 * @param {string} vaultId
353 * @param {{ starterDir?: string, onReject?: (name: string, reason: string) => void }} [options]
354 * @returns {{ seeded: number, skipped: number }}
355 */
356 export function seedStarterTasks(dataDir, vaultId, options = {}) {
357 const starterDir = options.starterDir ?? path.join(getRepoRoot(), STARTER_TASKS_DIRNAME);
358 const onReject = options.onReject ?? ((name, reason) => {
359 console.warn(`[task-store] rejected starter bundle ${name}: ${reason}`);
360 });
361
362 if (!fs.existsSync(starterDir)) {
363 return { seeded: 0, skipped: 0 };
364 }
365
366 const store = loadFlowStore(dataDir);
367 if (!store.vaults[vaultId]) {
368 store.vaults[vaultId] = {
369 flows: [],
370 steps: [],
371 runs: [],
372 candidates: [],
373 projections: [],
374 tasks: [],
375 task_loops: [],
376 orchestrator_graphs: [],
377 };
378 } else if (!Array.isArray(store.vaults[vaultId].tasks)) {
379 store.vaults[vaultId].tasks = [];
380 }
381 const vault = store.vaults[vaultId];
382
383 let seeded = 0;
384 let skipped = 0;
385
386 const files = fs.readdirSync(starterDir).filter((f) => f.startsWith('task_') && f.endsWith('.json')).sort();
387 for (const file of files) {
388 let raw;
389 try {
390 raw = JSON.parse(fs.readFileSync(path.join(starterDir, file), 'utf8'));
391 } catch {
392 onReject(file, 'invalid JSON');
393 continue;
394 }
395
396 const validated = validateTaskRecord(raw);
397 if (!validated.ok) {
398 onReject(file, validated.reason);
399 continue;
400 }
401
402 const { task } = validated;
403 const exists = vault.tasks.some((t) => t.task_id === task.task_id);
404 if (exists) {
405 skipped += 1;
406 continue;
407 }
408
409 vault.tasks.push(task);
410 seeded += 1;
411 }
412
413 if (seeded > 0) {
414 saveFlowStore(dataDir, store);
415 }
416
417 return { seeded, skipped };
418 }
419
420 /**
421 * Seed SD-2 anchor run when absent (read-only; delegates to flow-store P-FLOW seed).
422 *
423 * @param {string} dataDir
424 * @param {string} vaultId
425 * @returns {{ seeded: boolean }}
426 */
427 export function seedSd2AnchorRun(dataDir, vaultId) {
428 return seedOverseerAnchorRun(dataDir, vaultId);
429 }
430
431 /**
432 * @param {string} dataDir
433 * @param {string} vaultId
434 * @param {{ starterDir?: string, seedSd2Run?: boolean }} [options]
435 */
436 function ensureStarterSeed(dataDir, vaultId, options = {}) {
437 const store = loadFlowStore(dataDir);
438 const vault = store.vaults[vaultId];
439 if (vault && vault.tasks.length > 0) return;
440 seedStarterTasks(dataDir, vaultId, { starterDir: options.starterDir });
441 if (options.seedSd2Run !== false) {
442 seedSd2AnchorRun(dataDir, vaultId);
443 }
444 }
445
446 /**
447 * Compare tasks for list ordering: due_at asc (nulls last), then task_id asc.
448 *
449 * @param {StoredTask} a
450 * @param {StoredTask} b
451 * @returns {number}
452 */
453 function compareTasksForList(a, b) {
454 const aDue = a.due_at;
455 const bDue = b.due_at;
456 if (aDue === null && bDue === null) {
457 return a.task_id.localeCompare(b.task_id);
458 }
459 if (aDue === null) return 1;
460 if (bDue === null) return -1;
461 const t = Date.parse(aDue) - Date.parse(bDue);
462 if (t !== 0) return t;
463 return a.task_id.localeCompare(b.task_id);
464 }
465
466 /**
467 * List scope-visible tasks (content-minimized summaries).
468 *
469 * @param {string} dataDir
470 * @param {string} vaultId
471 * @param {{
472 * visibleScopes?: Set<TaskScope>,
473 * filterScopes?: Set<TaskScope>,
474 * effectiveScope: TaskScope,
475 * workspaceId?: string,
476 * status?: string,
477 * kind?: string,
478 * loopRef?: string,
479 * occurrenceKey?: string,
480 * limit?: number,
481 * starterDir?: string,
482 * }} query
483 * @returns {{ schema: 'knowtation.task_list/v0', vault_id: string, effective_scope: TaskScope, tasks: object[], truncated: boolean }}
484 */
485 export function listTasks(dataDir, vaultId, query) {
486 ensureStarterSeed(dataDir, vaultId, { starterDir: query.starterDir });
487
488 const visibleScopes = query.visibleScopes ?? query.filterScopes ?? new Set(['personal']);
489 const filterScopes = query.filterScopes ?? visibleScopes;
490 const workspaceId =
491 typeof query.workspaceId === 'string' && query.workspaceId.trim() ? query.workspaceId.trim() : '';
492 const statusFilter =
493 typeof query.status === 'string' && query.status.trim() ? query.status.trim() : '';
494 const kindFilter = typeof query.kind === 'string' && query.kind.trim() ? query.kind.trim() : '';
495 const loopRefFilter =
496 typeof query.loopRef === 'string' && query.loopRef.trim() ? query.loopRef.trim() : '';
497 const occurrenceKeyFilter =
498 typeof query.occurrenceKey === 'string' && query.occurrenceKey.trim()
499 ? query.occurrenceKey.trim()
500 : '';
501
502 let limit = typeof query.limit === 'number' ? query.limit : MAX_TASK_SUMMARIES;
503 if (!Number.isInteger(limit) || limit < 1) limit = MAX_TASK_SUMMARIES;
504 if (limit > MAX_TASK_SUMMARIES) limit = MAX_TASK_SUMMARIES;
505
506 const store = loadFlowStore(dataDir);
507 const vault = store.vaults[vaultId] ?? { tasks: [] };
508 const tasks = vault.tasks ?? [];
509
510 let candidates = tasks.filter((task) => {
511 if (!filterScopes.has(task.scope)) return false;
512 if (workspaceId && task.workspace_id !== workspaceId) return false;
513 if (statusFilter && task.status !== statusFilter) return false;
514 if (kindFilter && task.kind !== kindFilter) return false;
515 if (loopRefFilter && (task.loop_ref ?? '') !== loopRefFilter) return false;
516 if (occurrenceKeyFilter && (task.occurrence_key ?? '') !== occurrenceKeyFilter) return false;
517 return true;
518 });
519
520 candidates.sort(compareTasksForList);
521
522 const totalMatching = candidates.length;
523 let truncated = totalMatching > limit;
524 if (candidates.length > limit) {
525 candidates = candidates.slice(0, limit);
526 }
527
528 return {
529 schema: 'knowtation.task_list/v0',
530 vault_id: vaultId,
531 effective_scope: query.effectiveScope,
532 tasks: candidates.map((task) => taskSummaryForClient(task)),
533 truncated,
534 };
535 }
536
537 /**
538 * Get one task when readable in caller scope, or null (no existence leak).
539 *
540 * @param {string} dataDir
541 * @param {string} vaultId
542 * @param {string} taskId
543 * @param {{ visibleScopes?: Set<TaskScope>, starterDir?: string }} query
544 * @returns {StoredTask|null}
545 */
546 export function getTask(dataDir, vaultId, taskId, query) {
547 if (!TASK_ID_RE.test(taskId)) {
548 return null;
549 }
550
551 ensureStarterSeed(dataDir, vaultId, { starterDir: query.starterDir });
552
553 const filterScopes = query.visibleScopes ?? new Set(['personal']);
554 const store = loadFlowStore(dataDir);
555 const vault = store.vaults[vaultId];
556 if (!vault) return null;
557
558 const row = (vault.tasks ?? []).find((t) => t.task_id === taskId);
559 if (!row) return null;
560 if (!filterScopes.has(row.scope)) return null;
561 return row;
562 }
563
564 /**
565 * Resolve effective_scope for get responses (highest authorized scope).
566 *
567 * @param {Set<TaskScope>} visibleScopes
568 * @returns {TaskScope}
569 */
570 export function taskGetEffectiveScope(visibleScopes) {
571 return highestFlowScope(visibleScopes);
572 }
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago