task-write.mjs
1,403 lines 47.9 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Task + task-loop write proposal facade (Phase 2G-d-b).
3 *
4 * Typed facade over `/proposals` (SD-4): validate payload → check scope×role write
5 * authority → create proposal with server-stamped `proposal_kind` + `task_meta` →
6 * existing evaluation/approve/apply. Index mutations happen only at approve→apply via
7 * {@link reconcileApprovedTaskProposal}.
8 *
9 * @see docs/TASK-WRITE-PROPOSAL-CONTRACT-2G-d.md
10 */
11
12 import fs from 'fs';
13 import path from 'path';
14
15 import { fnv1a64Hex, stableStringify } from '../note-state-id.mjs';
16 import { loadFlowStore, saveFlowStore } from '../flow/flow-store.mjs';
17 import { resolveFlowWriteAuthority } from '../flow/flow-scope.mjs';
18 import { resolveHandlerVisibleScopes } from '../flow/flow-handlers.mjs';
19 import {
20 validateTaskRecord,
21 getTask,
22 taskForClient,
23 TASK_ID_RE,
24 TASK_KINDS,
25 TASK_STATUSES,
26 UID_HASH_REF_RE,
27 SAFE_ARTIFACT_REF_RE,
28 ARTIFACT_LINK_KINDS,
29 MAX_ARTIFACT_LINKS,
30 } from './task-store.mjs';
31 import {
32 validateTaskLoopRecord,
33 getTaskLoop,
34 taskLoopForClient,
35 LOOP_ID_RE,
36 LOOP_STATUSES,
37 } from './task-loop-store.mjs';
38 import {
39 SCOOLING_TASK_EXTERNAL_REF_RE,
40 resolveOptionalScoolingExternalRef,
41 readProposeExternalRefRaw,
42 } from '../scooling-external-ref.mjs';
43
44 export const TASK_STATE_ID_PREFIX = 'taskst1_';
45 export const LOOP_STATE_ID_PREFIX = 'loopst1_';
46 export const TASK_WRITE_POLICY_FILE = 'hub_task_write_policy.json';
47 export const TASK_PROPOSAL_SCHEMA = 'knowtation.task_proposal/v0';
48 export const TASK_INSTANCE_PROPOSAL_SCHEMA = 'knowtation.task_instance_proposal/v0';
49 export const TASK_PROPOSAL_SOURCE = 'task';
50 export const TASK_REVIEW_QUEUE = 'task-writes';
51
52 /** @typedef {'personal'|'project'|'org'} TaskScope */
53 /** @typedef {typeof TASK_STATUSES[number]} TaskStatus */
54
55 /** @type {Record<TaskStatus, TaskStatus[]>} */
56 const VALID_STATUS_TRANSITIONS = {
57 pending: ['in_progress', 'blocked', 'cancelled', 'done'],
58 in_progress: ['blocked', 'done', 'cancelled'],
59 blocked: ['in_progress', 'cancelled'],
60 done: [],
61 cancelled: [],
62 };
63
64 const PENDING_INSTANCE_STATUSES = /** @type {const} */ (['pending', 'in_progress', 'blocked']);
65
66 /**
67 * Canonical task subset for optimistic concurrency (excludes created/updated/truncated).
68 *
69 * @param {Record<string, unknown>} task
70 * @returns {Record<string, unknown>}
71 */
72 function canonicalTaskForState(task) {
73 return {
74 schema: 'knowtation.task/v0',
75 task_id: task.task_id,
76 kind: task.kind,
77 scope: task.scope,
78 status: task.status,
79 title: task.title,
80 workspace_id: task.workspace_id,
81 due_at: task.due_at ?? null,
82 assignee_ref: task.assignee_ref ?? null,
83 assigner_ref: task.assigner_ref ?? null,
84 run_ref: task.run_ref ?? null,
85 loop_ref: task.loop_ref ?? null,
86 occurrence_key: task.occurrence_key ?? null,
87 occurrence_at: task.occurrence_at ?? null,
88 series_status_snapshot: task.series_status_snapshot ?? null,
89 skip_reason: task.skip_reason ?? null,
90 artifact_links: Array.isArray(task.artifact_links) ? task.artifact_links : [],
91 };
92 }
93
94 /**
95 * Canonical loop subset for optimistic concurrency.
96 *
97 * @param {Record<string, unknown>} loop
98 * @returns {Record<string, unknown>}
99 */
100 function canonicalLoopForState(loop) {
101 return {
102 schema: 'knowtation.task_loop/v0',
103 loop_id: loop.loop_id,
104 kind: loop.kind,
105 scope: loop.scope,
106 status: loop.status,
107 title: loop.title,
108 workspace_id: loop.workspace_id,
109 recurrence: loop.recurrence,
110 timezone: loop.timezone,
111 flow_id: loop.flow_id ?? null,
112 boundary_policy: loop.boundary_policy,
113 memory_links: Array.isArray(loop.memory_links) ? loop.memory_links : [],
114 handoff_refs: Array.isArray(loop.handoff_refs) ? loop.handoff_refs : [],
115 until_at: loop.until_at ?? null,
116 };
117 }
118
119 /**
120 * @param {Record<string, unknown>} task
121 * @returns {string}
122 */
123 export function taskStateId(task) {
124 const payload = stableStringify(canonicalTaskForState(task || {}));
125 return TASK_STATE_ID_PREFIX + fnv1a64Hex(Buffer.from(payload, 'utf8'));
126 }
127
128 /**
129 * @param {Record<string, unknown>} loop
130 * @returns {string}
131 */
132 export function loopStateId(loop) {
133 const payload = stableStringify(canonicalLoopForState(loop || {}));
134 return LOOP_STATE_ID_PREFIX + fnv1a64Hex(Buffer.from(payload, 'utf8'));
135 }
136
137 /** @returns {string} */
138 export function absentTaskStateId() {
139 return TASK_STATE_ID_PREFIX + fnv1a64Hex(Buffer.from([0x00]));
140 }
141
142 /** @returns {string} */
143 export function absentLoopStateId() {
144 return LOOP_STATE_ID_PREFIX + fnv1a64Hex(Buffer.from([0x00]));
145 }
146
147 /** @param {unknown} v */
148 function envTriState(v) {
149 if (v === '1' || v === 'true') return true;
150 if (v === '0' || v === 'false') return false;
151 return null;
152 }
153
154 /**
155 * @param {string} dataDir
156 * @returns {{ task_writes_enabled?: boolean, task_writes_forbidden?: boolean, forbid_auto_done?: boolean }}
157 */
158 export function readTaskWritePolicyFile(dataDir) {
159 if (!dataDir) return {};
160 const fp = path.join(dataDir, TASK_WRITE_POLICY_FILE);
161 try {
162 if (!fs.existsSync(fp)) return {};
163 const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
164 if (!j || typeof j !== 'object') return {};
165 const out = {};
166 if (typeof j.task_writes_enabled === 'boolean') {
167 out.task_writes_enabled = j.task_writes_enabled;
168 }
169 if (typeof j.task_writes_forbidden === 'boolean') {
170 out.task_writes_forbidden = j.task_writes_forbidden;
171 }
172 if (typeof j.forbid_auto_done === 'boolean') {
173 out.forbid_auto_done = j.forbid_auto_done;
174 }
175 return out;
176 } catch {
177 return {};
178 }
179 }
180
181 /**
182 * @param {string} dataDir
183 * @returns {boolean}
184 */
185 export function getTaskWritesEnabled(dataDir) {
186 const fromEnv = envTriState(process.env.TASK_WRITES_ENABLED);
187 if (fromEnv !== null) return fromEnv;
188 return readTaskWritePolicyFile(dataDir).task_writes_enabled === true;
189 }
190
191 /**
192 * @param {string} dataDir
193 * @returns {boolean}
194 */
195 export function getTaskWritesForbidden(dataDir) {
196 const fromEnv = envTriState(process.env.TASK_WRITES_FORBIDDEN);
197 if (fromEnv !== null) return fromEnv;
198 return readTaskWritePolicyFile(dataDir).task_writes_forbidden === true;
199 }
200
201 /**
202 * @param {string} dataDir
203 * @returns {boolean}
204 */
205 export function getForbidAutoDone(dataDir) {
206 return readTaskWritePolicyFile(dataDir).forbid_auto_done === true;
207 }
208
209 /**
210 * @param {number} status
211 * @param {string} code
212 * @param {string} [error]
213 */
214 function refuse(status, code, error) {
215 return { ok: false, status, error: error ?? code, code };
216 }
217
218 /**
219 * @param {Set<TaskScope>} visibleScopes
220 * @param {TaskScope} targetScope
221 * @param {string} [taskKind]
222 * @returns {{ ok: true } | { ok: false, status: number, error: string, code: string }}
223 */
224 export function resolveTaskWriteAuthority(visibleScopes, targetScope, taskKind) {
225 const authority = resolveFlowWriteAuthority(visibleScopes, targetScope);
226 if (!authority.ok) {
227 return {
228 ok: false,
229 status: authority.status,
230 error: authority.error === 'Flow write scope not authorized'
231 ? 'Task write scope not authorized'
232 : authority.error,
233 code: authority.code === 'FLOW_SCOPE_DENIED' ? 'TASK_SCOPE_DENIED' : authority.code,
234 };
235 }
236 if (
237 taskKind === 'assignment' &&
238 (targetScope === 'project' || targetScope === 'org')
239 ) {
240 return refuse(
241 403,
242 'TASK_CLASSROOM_AUTHORITY_REQUIRED',
243 'Classroom assignment authority required (Phase 2C)',
244 );
245 }
246 return { ok: true };
247 }
248
249 /**
250 * @param {object} input
251 * @returns {{ visibleScopes: Set<TaskScope>, ambiguous: boolean }}
252 */
253 function resolveWriteScopes(input) {
254 return resolveHandlerVisibleScopes(input);
255 }
256
257 /**
258 * @param {string} dataDir
259 * @param {string} vaultId
260 * @param {Set<TaskScope>} visibleScopes
261 * @param {string} taskId
262 * @param {string} [starterDir]
263 * @returns {import('./task-store.mjs').StoredTask|null}
264 */
265 function getVisibleTask(dataDir, vaultId, visibleScopes, taskId, starterDir) {
266 return getTask(dataDir, vaultId, taskId, { visibleScopes, starterDir });
267 }
268
269 /**
270 * @param {string} dataDir
271 * @param {string} vaultId
272 * @param {Set<TaskScope>} visibleScopes
273 * @param {string} loopId
274 * @param {object} [seedOptions]
275 * @returns {import('./task-loop-store.mjs').StoredTaskLoop|null}
276 */
277 function getVisibleLoop(dataDir, vaultId, visibleScopes, loopId, seedOptions = {}) {
278 return getTaskLoop(dataDir, vaultId, loopId, { visibleScopes, ...seedOptions });
279 }
280
281 /**
282 * @param {string} loopId
283 * @param {string} occurrenceKey
284 * @returns {{ ok: true, taskId: string } | { ok: false }}
285 */
286 export function computeMaterializeTaskId(loopId, occurrenceKey) {
287 const loopToken = loopId.replace(/^loop_/, '').replace(/[^a-z0-9_]/g, '_').slice(0, 20);
288 const occToken = occurrenceKey
289 .replace(/[^A-Za-z0-9._:-]/g, '_')
290 .replace(/:/g, '_')
291 .replace(/\./g, '_')
292 .replace(/-/g, '_')
293 .toLowerCase()
294 .slice(0, 24);
295 const taskId = `task_${loopToken}_${occToken}`;
296 if (!TASK_ID_RE.test(taskId)) {
297 return { ok: false };
298 }
299 return { ok: true, taskId };
300 }
301
302 /**
303 * @param {string} dataDir
304 * @param {string} vaultId
305 * @param {string} loopId
306 * @returns {Set<string>}
307 */
308 function existingOccurrenceKeys(dataDir, vaultId, loopId) {
309 const store = loadFlowStore(dataDir);
310 const vault = store.vaults[vaultId];
311 const keys = new Set();
312 if (!vault) return keys;
313 for (const task of vault.tasks ?? []) {
314 if (task.loop_ref === loopId && task.occurrence_key) {
315 keys.add(task.occurrence_key);
316 }
317 }
318 return keys;
319 }
320
321 /**
322 * Lazy next occurrence key (OD-3 subset — manual + interval week).
323 *
324 * @param {object} loop
325 * @param {Set<string>} existingKeys
326 * @returns {string}
327 */
328 export function computeLazyOccurrenceKey(loop, existingKeys) {
329 const recurrence = loop.recurrence;
330 if (recurrence?.kind === 'interval' && recurrence.unit === 'week') {
331 const anchor = recurrence.anchor_at ? new Date(recurrence.anchor_at) : new Date();
332 const year = anchor.getUTCFullYear();
333 const week = isoWeekNumber(anchor);
334 let candidate = `${year}-W${String(week).padStart(2, '0')}`;
335 let offset = 0;
336 while (existingKeys.has(candidate)) {
337 offset += 1;
338 candidate = `${year}-W${String(week + offset).padStart(2, '0')}`;
339 }
340 return candidate;
341 }
342 if (recurrence?.kind === 'manual') {
343 let n = 1;
344 while (existingKeys.has(`manual-${n}`)) n += 1;
345 return `manual-${n}`;
346 }
347 let n = 1;
348 while (existingKeys.has(`lazy-${n}`)) n += 1;
349 return `lazy-${n}`;
350 }
351
352 /**
353 * @param {Date} date
354 * @returns {number}
355 */
356 function isoWeekNumber(date) {
357 const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
358 d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
359 const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
360 return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
361 }
362
363 /**
364 * Gate + intent check shared by all propose handlers.
365 *
366 * @param {object} input
367 * @returns {{ ok: true, intent: string, visibleScopes: Set<TaskScope> } | ReturnType<typeof refuse>}
368 */
369 function commonProposeGate(input) {
370 if (getTaskWritesForbidden(input.dataDir)) {
371 return refuse(403, 'TASK_WRITES_DISABLED', 'Task writes forbidden by policy');
372 }
373 if (!getTaskWritesEnabled(input.dataDir)) {
374 return refuse(403, 'TASK_WRITES_DISABLED', 'Task writes are disabled');
375 }
376 if (typeof input.createProposal !== 'function') {
377 return refuse(500, 'RUNTIME_ERROR', 'createProposal is required');
378 }
379 const intent = typeof input.intent === 'string' ? input.intent.trim() : '';
380 if (!intent) {
381 return refuse(400, 'TASK_DRAFT_INVALID', 'intent is required');
382 }
383 const resolved = resolveWriteScopes(input);
384 if (resolved.ambiguous) {
385 return refuse(400, 'TASK_SCOPE_AMBIGUOUS', 'Ambiguous task scope');
386 }
387 const ext = resolveTaskProposeExternalRef(input);
388 if (!ext.ok) return ext;
389 return { ok: true, intent, visibleScopes: resolved.visibleScopes, externalRef: ext.externalRef };
390 }
391
392 /**
393 * Support sync (self-hosted) and async (hosted canister) createProposal injectors.
394 *
395 * @param {object} input
396 * @param {object} proposalInput
397 */
398 async function createProposalRecord(input, proposalInput) {
399 const withSession = {
400 ...proposalInput,
401 ...(typeof input.sessionBound === 'boolean' ? { session_bound: input.sessionBound } : {}),
402 };
403 return await Promise.resolve(input.createProposal(input.dataDir, withSession));
404 }
405
406 /**
407 * Optional Scooling task external_ref on propose (§FCA.4.1). Malformed → 400; absent → ok.
408 *
409 * @param {object} input
410 * @returns {{ ok: true, externalRef: string|undefined } | ReturnType<typeof refuse>}
411 */
412 function resolveTaskProposeExternalRef(input) {
413 const resolved = resolveOptionalScoolingExternalRef(
414 readProposeExternalRefRaw(input),
415 SCOOLING_TASK_EXTERNAL_REF_RE,
416 );
417 if (!resolved.ok) {
418 return refuse(resolved.status, resolved.code, resolved.error);
419 }
420 return { ok: true, externalRef: resolved.externalRef };
421 }
422
423 /**
424 * @param {string} proposalId
425 * @returns {string}
426 */
427 function taskProposalMirrorPath(proposalId) {
428 return `meta/tasks/proposals/${proposalId}.json`;
429 }
430
431 /**
432 * One-time task propose — task_create | task_status_update | task_assign | task_artifact_link.
433 *
434 * @param {object} input
435 * @returns {{ ok: true, payload: object } | ReturnType<typeof refuse>}
436 */
437 export async function handleTaskProposeRequest(input) {
438 const gate = commonProposeGate(input);
439 if (!gate.ok) return gate;
440
441 const body = input.body && typeof input.body === 'object' ? input.body : {};
442 const proposalKindRaw =
443 typeof input.proposalKind === 'string'
444 ? input.proposalKind.trim()
445 : typeof body.proposal_kind === 'string'
446 ? body.proposal_kind.trim()
447 : '';
448
449 if (proposalKindRaw === 'task_create') {
450 return await handleTaskCreatePropose(input, gate.intent, gate.visibleScopes, gate.externalRef);
451 }
452 if (proposalKindRaw === 'task_status_update') {
453 return await handleTaskStatusUpdatePropose(input, gate.intent, gate.visibleScopes, gate.externalRef);
454 }
455 if (proposalKindRaw === 'task_assign') {
456 return await handleTaskAssignPropose(input, gate.intent, gate.visibleScopes, gate.externalRef);
457 }
458 if (proposalKindRaw === 'task_artifact_link') {
459 return await handleTaskArtifactLinkPropose(input, gate.intent, gate.visibleScopes, gate.externalRef);
460 }
461 return refuse(400, 'TASK_DRAFT_INVALID', 'proposal_kind must be task_create|task_status_update|task_assign|task_artifact_link');
462 }
463
464 /**
465 * @param {object} input
466 * @param {string} intent
467 * @param {Set<TaskScope>} visibleScopes
468 * @param {string|undefined} externalRef
469 */
470 async function handleTaskCreatePropose(input, intent, visibleScopes, externalRef) {
471 const body = input.body && typeof input.body === 'object' ? input.body : {};
472 const taskRaw = body.task && typeof body.task === 'object' ? body.task : body;
473 if (!taskRaw || typeof taskRaw !== 'object') {
474 return refuse(400, 'TASK_DRAFT_INVALID', 'task object is required');
475 }
476 if (taskRaw.loop_ref != null && taskRaw.loop_ref !== null) {
477 return refuse(400, 'TASK_DRAFT_INVALID', 'loop_ref must be absent at task_create — use task_instance_materialize');
478 }
479 if (taskRaw.occurrence_key != null && taskRaw.occurrence_key !== null) {
480 return refuse(400, 'TASK_DRAFT_INVALID', 'occurrence_key must be absent at task_create');
481 }
482 if (taskRaw.run_ref != null && taskRaw.run_ref !== null) {
483 return refuse(400, 'TASK_DRAFT_INVALID', 'run_ref must be null at task_create');
484 }
485
486 const now = new Date().toISOString();
487 const draft = {
488 ...taskRaw,
489 schema: 'knowtation.task/v0',
490 status: taskRaw.status ?? 'pending',
491 artifact_links: Array.isArray(taskRaw.artifact_links) ? taskRaw.artifact_links : [],
492 run_ref: null,
493 loop_ref: null,
494 occurrence_key: null,
495 occurrence_at: null,
496 series_status_snapshot: null,
497 skip_reason: null,
498 created: now,
499 updated: now,
500 truncated: false,
501 };
502
503 const validated = validateTaskRecord(draft);
504 if (!validated.ok) {
505 return refuse(400, 'TASK_DRAFT_INVALID', validated.reason);
506 }
507 const { task } = validated;
508
509 const authority = resolveTaskWriteAuthority(visibleScopes, task.scope, task.kind);
510 if (!authority.ok) return authority;
511
512 const existing = getVisibleTask(input.dataDir, input.vaultId, visibleScopes, task.task_id, input.starterDir);
513 if (existing) {
514 return refuse(409, 'TASK_LINEAGE_CONFLICT', 'task_id already exists in scope');
515 }
516
517 const proposalBaseStateId = absentTaskStateId();
518 const proposalBody = JSON.stringify({ proposal_kind: 'task_create', task }, null, 2);
519
520 const proposal = await createProposalRecord(input, {
521 path: taskProposalMirrorPath('pending'),
522 body: proposalBody,
523 frontmatter: { type: 'task_proposal', task_id: task.task_id, proposal_kind: 'task_create' },
524 intent,
525 base_state_id: proposalBaseStateId,
526 source: TASK_PROPOSAL_SOURCE,
527 vault_id: input.vaultId,
528 proposed_by: typeof input.userId === 'string' && input.userId.trim() ? input.userId.trim() : undefined,
529 review_queue: TASK_REVIEW_QUEUE,
530 ...(externalRef ? { external_ref: externalRef } : {}),
531 task_meta: {
532 record_kind: 'task',
533 proposal_kind: 'task_create',
534 task_id: task.task_id,
535 loop_id: null,
536 occurrence_key: null,
537 },
538 });
539
540 updateProposalPath(input.dataDir, proposal.proposal_id);
541
542 return {
543 ok: true,
544 payload: {
545 schema: TASK_PROPOSAL_SCHEMA,
546 proposal_id: proposal.proposal_id,
547 proposal_kind: 'task_create',
548 task_id: task.task_id,
549 loop_id: null,
550 base_state_id: proposalBaseStateId,
551 scope: task.scope,
552 auto_approvable: false,
553 status: 'proposed',
554 review_queue: TASK_REVIEW_QUEUE,
555 },
556 };
557 }
558
559 /**
560 * @param {object} input
561 * @param {string} intent
562 * @param {Set<TaskScope>} visibleScopes
563 */
564 async function handleTaskStatusUpdatePropose(input, intent, visibleScopes, externalRef) {
565 const body = input.body && typeof input.body === 'object' ? input.body : {};
566 const taskId = typeof body.task_id === 'string' ? body.task_id.trim() : '';
567 const baseStateId = typeof body.base_state_id === 'string' ? body.base_state_id.trim() : '';
568 const newStatus = typeof body.status === 'string' ? body.status.trim() : '';
569 const skipReason = body.skip_reason != null ? String(body.skip_reason).slice(0, 256) : null;
570
571 if (!taskId || !TASK_ID_RE.test(taskId)) {
572 return refuse(400, 'TASK_DRAFT_INVALID', 'task_id is required');
573 }
574 if (!baseStateId.startsWith(TASK_STATE_ID_PREFIX)) {
575 return refuse(400, 'TASK_DRAFT_INVALID', 'base_state_id is required');
576 }
577 if (!TASK_STATUSES.includes(/** @type {TaskStatus} */ (newStatus))) {
578 return refuse(400, 'TASK_DRAFT_INVALID', 'invalid status');
579 }
580
581 const existing = getVisibleTask(input.dataDir, input.vaultId, visibleScopes, taskId, input.starterDir);
582 if (!existing) {
583 return refuse(404, 'unknown_task', 'unknown_task');
584 }
585
586 const authority = resolveTaskWriteAuthority(visibleScopes, existing.scope, existing.kind);
587 if (!authority.ok) return authority;
588
589 const canonical = taskForClient(existing);
590 const serverStateId = taskStateId(canonical);
591 if (serverStateId !== baseStateId) {
592 return refuse(409, 'TASK_LINEAGE_CONFLICT', 'task changed since proposal was based');
593 }
594
595 const allowed = VALID_STATUS_TRANSITIONS[existing.status] ?? [];
596 if (!allowed.includes(/** @type {TaskStatus} */ (newStatus))) {
597 return refuse(400, 'TASK_DRAFT_INVALID', 'invalid status transition');
598 }
599 if (newStatus === 'done' && getForbidAutoDone(input.dataDir)) {
600 return refuse(403, 'TASK_SCOPE_DENIED', 'auto-complete to done forbidden by policy');
601 }
602
603 const patch = { status: newStatus, skip_reason: newStatus === 'cancelled' ? skipReason : null };
604 const proposalBody = JSON.stringify(
605 { proposal_kind: 'task_status_update', task_id: taskId, scope: existing.scope, ...patch },
606 null,
607 2,
608 );
609
610 const proposal = await createProposalRecord(input, {
611 path: taskProposalMirrorPath('pending'),
612 body: proposalBody,
613 frontmatter: { type: 'task_proposal', task_id: taskId, proposal_kind: 'task_status_update' },
614 intent,
615 base_state_id: baseStateId,
616 source: TASK_PROPOSAL_SOURCE,
617 vault_id: input.vaultId,
618 proposed_by: typeof input.userId === 'string' && input.userId.trim() ? input.userId.trim() : undefined,
619 review_queue: TASK_REVIEW_QUEUE,
620 ...(externalRef ? { external_ref: externalRef } : {}),
621 task_meta: {
622 record_kind: 'task',
623 proposal_kind: 'task_status_update',
624 task_id: taskId,
625 loop_id: null,
626 occurrence_key: null,
627 },
628 });
629
630 updateProposalPath(input.dataDir, proposal.proposal_id);
631
632 return buildTaskProposalEnvelope(proposal.proposal_id, 'task_status_update', taskId, null, baseStateId, existing.scope);
633 }
634
635 /**
636 * @param {object} input
637 * @param {string} intent
638 * @param {Set<TaskScope>} visibleScopes
639 */
640 async function handleTaskAssignPropose(input, intent, visibleScopes, externalRef) {
641 const body = input.body && typeof input.body === 'object' ? input.body : {};
642 const taskId = typeof body.task_id === 'string' ? body.task_id.trim() : '';
643 const baseStateId = typeof body.base_state_id === 'string' ? body.base_state_id.trim() : '';
644 const assigneeRef = body.assignee_ref;
645 const assignerRef = body.assigner_ref ?? null;
646
647 if (!taskId || !baseStateId.startsWith(TASK_STATE_ID_PREFIX)) {
648 return refuse(400, 'TASK_DRAFT_INVALID', 'task_id and base_state_id required');
649 }
650 if (!isValidTaskAssigneeRef(assigneeRef)) {
651 return refuse(400, 'TASK_DRAFT_INVALID', 'assignee_ref must be uid_hash:<64-hex>, agent_<id>, *, or null');
652 }
653
654 const existing = getVisibleTask(input.dataDir, input.vaultId, visibleScopes, taskId, input.starterDir);
655 if (!existing) return refuse(404, 'unknown_task', 'unknown_task');
656
657 const authority = resolveTaskWriteAuthority(visibleScopes, existing.scope, existing.kind);
658 if (!authority.ok) return authority;
659
660 const serverStateId = taskStateId(taskForClient(existing));
661 if (serverStateId !== baseStateId) {
662 return refuse(409, 'TASK_LINEAGE_CONFLICT', 'task changed since proposal was based');
663 }
664
665 const proposalBody = JSON.stringify(
666 {
667 proposal_kind: 'task_assign',
668 task_id: taskId,
669 scope: existing.scope,
670 assignee_ref: assigneeRef,
671 assigner_ref: assignerRef,
672 },
673 null,
674 2,
675 );
676
677 const proposal = await createProposalRecord(input, {
678 path: taskProposalMirrorPath('pending'),
679 body: proposalBody,
680 frontmatter: { type: 'task_proposal', task_id: taskId, proposal_kind: 'task_assign' },
681 intent,
682 base_state_id: baseStateId,
683 source: TASK_PROPOSAL_SOURCE,
684 vault_id: input.vaultId,
685 proposed_by: typeof input.userId === 'string' && input.userId.trim() ? input.userId.trim() : undefined,
686 review_queue: TASK_REVIEW_QUEUE,
687 ...(externalRef ? { external_ref: externalRef } : {}),
688 task_meta: {
689 record_kind: 'task',
690 proposal_kind: 'task_assign',
691 task_id: taskId,
692 loop_id: null,
693 occurrence_key: null,
694 },
695 });
696
697 updateProposalPath(input.dataDir, proposal.proposal_id);
698 return buildTaskProposalEnvelope(proposal.proposal_id, 'task_assign', taskId, null, baseStateId, existing.scope);
699 }
700
701 /**
702 * @param {object} input
703 * @param {string} intent
704 * @param {Set<TaskScope>} visibleScopes
705 */
706 async function handleTaskArtifactLinkPropose(input, intent, visibleScopes, externalRef) {
707 const body = input.body && typeof input.body === 'object' ? input.body : {};
708 const taskId = typeof body.task_id === 'string' ? body.task_id.trim() : '';
709 const baseStateId = typeof body.base_state_id === 'string' ? body.base_state_id.trim() : '';
710 const link = body.artifact_link ?? body.artifact;
711
712 if (!taskId || !baseStateId.startsWith(TASK_STATE_ID_PREFIX)) {
713 return refuse(400, 'TASK_DRAFT_INVALID', 'task_id and base_state_id required');
714 }
715 if (!link || typeof link !== 'object') {
716 return refuse(400, 'TASK_DRAFT_INVALID', 'artifact_link is required');
717 }
718 const row = /** @type {Record<string, unknown>} */ (link);
719 if (!ARTIFACT_LINK_KINDS.includes(/** @type {typeof ARTIFACT_LINK_KINDS[number]} */ (row.kind))) {
720 return refuse(400, 'TASK_DRAFT_INVALID', 'invalid artifact_link kind');
721 }
722 if (typeof row.ref !== 'string' || !SAFE_ARTIFACT_REF_RE.test(row.ref)) {
723 return refuse(400, 'TASK_DRAFT_INVALID', 'invalid artifact_link ref');
724 }
725
726 const existing = getVisibleTask(input.dataDir, input.vaultId, visibleScopes, taskId, input.starterDir);
727 if (!existing) return refuse(404, 'unknown_task', 'unknown_task');
728
729 const authority = resolveTaskWriteAuthority(visibleScopes, existing.scope, existing.kind);
730 if (!authority.ok) return authority;
731
732 const serverStateId = taskStateId(taskForClient(existing));
733 if (serverStateId !== baseStateId) {
734 return refuse(409, 'TASK_LINEAGE_CONFLICT', 'task changed since proposal was based');
735 }
736
737 const proposalBody = JSON.stringify(
738 {
739 proposal_kind: 'task_artifact_link',
740 task_id: taskId,
741 scope: existing.scope,
742 artifact_link: { kind: row.kind, ref: row.ref },
743 },
744 null,
745 2,
746 );
747
748 const proposal = await createProposalRecord(input, {
749 path: taskProposalMirrorPath('pending'),
750 body: proposalBody,
751 frontmatter: { type: 'task_proposal', task_id: taskId, proposal_kind: 'task_artifact_link' },
752 intent,
753 base_state_id: baseStateId,
754 source: TASK_PROPOSAL_SOURCE,
755 vault_id: input.vaultId,
756 proposed_by: typeof input.userId === 'string' && input.userId.trim() ? input.userId.trim() : undefined,
757 review_queue: TASK_REVIEW_QUEUE,
758 ...(externalRef ? { external_ref: externalRef } : {}),
759 task_meta: {
760 record_kind: 'task',
761 proposal_kind: 'task_artifact_link',
762 task_id: taskId,
763 loop_id: null,
764 occurrence_key: null,
765 },
766 });
767
768 updateProposalPath(input.dataDir, proposal.proposal_id);
769 return buildTaskProposalEnvelope(proposal.proposal_id, 'task_artifact_link', taskId, null, baseStateId, existing.scope);
770 }
771
772 /**
773 * Loop series propose — task_loop_create | task_loop_pause | task_loop_cancel.
774 *
775 * @param {object} input
776 * @returns {{ ok: true, payload: object } | ReturnType<typeof refuse>}
777 */
778 export async function handleTaskLoopProposeRequest(input) {
779 const gate = commonProposeGate(input);
780 if (!gate.ok) return gate;
781
782 const body = input.body && typeof input.body === 'object' ? input.body : {};
783 const proposalKindRaw =
784 typeof input.proposalKind === 'string'
785 ? input.proposalKind.trim()
786 : typeof body.proposal_kind === 'string'
787 ? body.proposal_kind.trim()
788 : '';
789
790 if (proposalKindRaw === 'task_loop_create') {
791 return await handleTaskLoopCreatePropose(input, gate.intent, gate.visibleScopes, gate.externalRef);
792 }
793 if (proposalKindRaw === 'task_loop_pause') {
794 return await handleTaskLoopPausePropose(input, gate.intent, gate.visibleScopes, gate.externalRef);
795 }
796 if (proposalKindRaw === 'task_loop_cancel') {
797 return await handleTaskLoopCancelPropose(input, gate.intent, gate.visibleScopes, gate.externalRef);
798 }
799 return refuse(400, 'TASK_DRAFT_INVALID', 'proposal_kind must be task_loop_create|task_loop_pause|task_loop_cancel');
800 }
801
802 /**
803 * @param {object} input
804 * @param {string} intent
805 * @param {Set<TaskScope>} visibleScopes
806 */
807 async function handleTaskLoopCreatePropose(input, intent, visibleScopes, externalRef) {
808 const body = input.body && typeof input.body === 'object' ? input.body : {};
809 const loopRaw = body.loop && typeof body.loop === 'object' ? body.loop : body;
810 if (!loopRaw || typeof loopRaw !== 'object') {
811 return refuse(400, 'TASK_DRAFT_INVALID', 'loop object is required');
812 }
813 if (loopRaw.status && loopRaw.status !== 'active') {
814 return refuse(400, 'TASK_DRAFT_INVALID', 'initial loop status must be active');
815 }
816
817 const now = new Date().toISOString();
818 const draft = {
819 ...loopRaw,
820 schema: 'knowtation.task_loop/v0',
821 status: 'active',
822 memory_links: Array.isArray(loopRaw.memory_links) ? loopRaw.memory_links : [],
823 created: now,
824 updated: now,
825 truncated: false,
826 };
827
828 const validated = validateTaskLoopRecord(draft);
829 if (!validated.ok) {
830 return refuse(400, 'TASK_DRAFT_INVALID', validated.reason);
831 }
832 const { loop } = validated;
833
834 const authority = resolveTaskWriteAuthority(visibleScopes, loop.scope, loop.kind);
835 if (!authority.ok) return authority;
836
837 const existing = getVisibleLoop(input.dataDir, input.vaultId, visibleScopes, loop.loop_id, {
838 starterDir: input.starterDir,
839 });
840 if (existing) {
841 return refuse(409, 'TASK_LOOP_LINEAGE_CONFLICT', 'loop_id already exists in scope');
842 }
843
844 const proposalBaseStateId = absentLoopStateId();
845 const proposalBody = JSON.stringify({ proposal_kind: 'task_loop_create', loop }, null, 2);
846
847 const proposal = await createProposalRecord(input, {
848 path: taskProposalMirrorPath('pending'),
849 body: proposalBody,
850 frontmatter: { type: 'task_loop_proposal', loop_id: loop.loop_id, proposal_kind: 'task_loop_create' },
851 intent,
852 base_state_id: proposalBaseStateId,
853 source: TASK_PROPOSAL_SOURCE,
854 vault_id: input.vaultId,
855 proposed_by: typeof input.userId === 'string' && input.userId.trim() ? input.userId.trim() : undefined,
856 review_queue: TASK_REVIEW_QUEUE,
857 ...(externalRef ? { external_ref: externalRef } : {}),
858 task_meta: {
859 record_kind: 'task_loop',
860 proposal_kind: 'task_loop_create',
861 task_id: null,
862 loop_id: loop.loop_id,
863 occurrence_key: null,
864 },
865 });
866
867 updateProposalPath(input.dataDir, proposal.proposal_id);
868
869 return {
870 ok: true,
871 payload: {
872 schema: TASK_PROPOSAL_SCHEMA,
873 proposal_id: proposal.proposal_id,
874 proposal_kind: 'task_loop_create',
875 task_id: null,
876 loop_id: loop.loop_id,
877 base_state_id: proposalBaseStateId,
878 scope: loop.scope,
879 auto_approvable: false,
880 status: 'proposed',
881 review_queue: TASK_REVIEW_QUEUE,
882 },
883 };
884 }
885
886 /**
887 * @param {object} input
888 * @param {string} intent
889 * @param {Set<TaskScope>} visibleScopes
890 */
891 async function handleTaskLoopPausePropose(input, intent, visibleScopes, externalRef) {
892 const body = input.body && typeof input.body === 'object' ? input.body : {};
893 const loopId = typeof body.loop_id === 'string' ? body.loop_id.trim() : '';
894 const baseStateId = typeof body.base_state_id === 'string' ? body.base_state_id.trim() : '';
895
896 if (!loopId || !LOOP_ID_RE.test(loopId) || !baseStateId.startsWith(LOOP_STATE_ID_PREFIX)) {
897 return refuse(400, 'TASK_DRAFT_INVALID', 'loop_id and base_state_id required');
898 }
899
900 const existing = getVisibleLoop(input.dataDir, input.vaultId, visibleScopes, loopId, {
901 starterDir: input.starterDir,
902 });
903 if (!existing) return refuse(404, 'unknown_task_loop', 'unknown_task_loop');
904
905 const authority = resolveTaskWriteAuthority(visibleScopes, existing.scope, existing.kind);
906 if (!authority.ok) return authority;
907
908 const serverStateId = loopStateId(taskLoopForClient(existing));
909 if (serverStateId !== baseStateId) {
910 return refuse(409, 'TASK_LOOP_LINEAGE_CONFLICT', 'loop changed since proposal was based');
911 }
912 if (existing.status !== 'active') {
913 return refuse(409, 'TASK_LOOP_NOT_ACTIVE', 'loop is not active');
914 }
915
916 const proposalBody = JSON.stringify(
917 { proposal_kind: 'task_loop_pause', loop_id: loopId, scope: existing.scope },
918 null,
919 2,
920 );
921 const proposal = await createProposalRecord(input, {
922 path: taskProposalMirrorPath('pending'),
923 body: proposalBody,
924 frontmatter: { type: 'task_loop_proposal', loop_id: loopId, proposal_kind: 'task_loop_pause' },
925 intent,
926 base_state_id: baseStateId,
927 source: TASK_PROPOSAL_SOURCE,
928 vault_id: input.vaultId,
929 proposed_by: typeof input.userId === 'string' && input.userId.trim() ? input.userId.trim() : undefined,
930 review_queue: TASK_REVIEW_QUEUE,
931 ...(externalRef ? { external_ref: externalRef } : {}),
932 task_meta: {
933 record_kind: 'task_loop',
934 proposal_kind: 'task_loop_pause',
935 task_id: null,
936 loop_id: loopId,
937 occurrence_key: null,
938 },
939 });
940
941 updateProposalPath(input.dataDir, proposal.proposal_id);
942 return buildTaskProposalEnvelope(proposal.proposal_id, 'task_loop_pause', null, loopId, baseStateId, existing.scope);
943 }
944
945 /**
946 * @param {object} input
947 * @param {string} intent
948 * @param {Set<TaskScope>} visibleScopes
949 */
950 async function handleTaskLoopCancelPropose(input, intent, visibleScopes, externalRef) {
951 const body = input.body && typeof input.body === 'object' ? input.body : {};
952 const loopId = typeof body.loop_id === 'string' ? body.loop_id.trim() : '';
953 const baseStateId = typeof body.base_state_id === 'string' ? body.base_state_id.trim() : '';
954
955 if (!loopId || !baseStateId.startsWith(LOOP_STATE_ID_PREFIX)) {
956 return refuse(400, 'TASK_DRAFT_INVALID', 'loop_id and base_state_id required');
957 }
958
959 const existing = getVisibleLoop(input.dataDir, input.vaultId, visibleScopes, loopId, {
960 starterDir: input.starterDir,
961 });
962 if (!existing) return refuse(404, 'unknown_task_loop', 'unknown_task_loop');
963
964 const authority = resolveTaskWriteAuthority(visibleScopes, existing.scope, existing.kind);
965 if (!authority.ok) return authority;
966
967 const serverStateId = loopStateId(taskLoopForClient(existing));
968 if (serverStateId !== baseStateId) {
969 return refuse(409, 'TASK_LOOP_LINEAGE_CONFLICT', 'loop changed since proposal was based');
970 }
971
972 const proposalBody = JSON.stringify(
973 { proposal_kind: 'task_loop_cancel', loop_id: loopId, scope: existing.scope },
974 null,
975 2,
976 );
977 const proposal = await createProposalRecord(input, {
978 path: taskProposalMirrorPath('pending'),
979 body: proposalBody,
980 frontmatter: { type: 'task_loop_proposal', loop_id: loopId, proposal_kind: 'task_loop_cancel' },
981 intent,
982 base_state_id: baseStateId,
983 source: TASK_PROPOSAL_SOURCE,
984 vault_id: input.vaultId,
985 proposed_by: typeof input.userId === 'string' && input.userId.trim() ? input.userId.trim() : undefined,
986 review_queue: TASK_REVIEW_QUEUE,
987 ...(externalRef ? { external_ref: externalRef } : {}),
988 task_meta: {
989 record_kind: 'task_loop',
990 proposal_kind: 'task_loop_cancel',
991 task_id: null,
992 loop_id: loopId,
993 occurrence_key: null,
994 },
995 });
996
997 updateProposalPath(input.dataDir, proposal.proposal_id);
998 return buildTaskProposalEnvelope(proposal.proposal_id, 'task_loop_cancel', null, loopId, baseStateId, existing.scope);
999 }
1000
1001 /**
1002 * Materialize one loop occurrence task.
1003 *
1004 * @param {object} input
1005 * @returns {{ ok: true, payload: object } | ReturnType<typeof refuse>}
1006 */
1007 export async function handleTaskInstanceMaterializeRequest(input) {
1008 const gate = commonProposeGate(input);
1009 if (!gate.ok) return gate;
1010
1011 const body = input.body && typeof input.body === 'object' ? input.body : {};
1012 const loopId = typeof body.loop_id === 'string' ? body.loop_id.trim() : typeof input.loopId === 'string' ? input.loopId.trim() : '';
1013 if (!loopId || !LOOP_ID_RE.test(loopId)) {
1014 return refuse(400, 'TASK_DRAFT_INVALID', 'loop_id is required');
1015 }
1016
1017 const loop = getVisibleLoop(input.dataDir, input.vaultId, gate.visibleScopes, loopId, {
1018 starterDir: input.starterDir,
1019 });
1020 if (!loop) return refuse(404, 'unknown_task_loop', 'unknown_task_loop');
1021
1022 const authority = resolveTaskWriteAuthority(gate.visibleScopes, loop.scope, loop.kind);
1023 if (!authority.ok) return authority;
1024
1025 if (loop.status !== 'active') {
1026 return refuse(409, 'TASK_LOOP_NOT_ACTIVE', 'loop is not active — cannot materialize');
1027 }
1028
1029 const baseStateId = typeof body.base_state_id === 'string' ? body.base_state_id.trim() : '';
1030 if (baseStateId) {
1031 const serverLoopStateId = loopStateId(taskLoopForClient(loop));
1032 if (serverLoopStateId !== baseStateId) {
1033 return refuse(409, 'TASK_LOOP_LINEAGE_CONFLICT', 'loop changed since materialize was based');
1034 }
1035 }
1036
1037 const existingKeys = existingOccurrenceKeys(input.dataDir, input.vaultId, loopId);
1038 let occurrenceKey =
1039 typeof body.occurrence_key === 'string' && body.occurrence_key.trim()
1040 ? body.occurrence_key.trim()
1041 : computeLazyOccurrenceKey(loop, existingKeys);
1042
1043 if (existingKeys.has(occurrenceKey)) {
1044 return refuse(409, 'TASK_OCCURRENCE_EXISTS', 'occurrence already materialized');
1045 }
1046
1047 const occurrenceAt =
1048 typeof body.occurrence_at === 'string' && body.occurrence_at.trim()
1049 ? body.occurrence_at.trim()
1050 : loop.recurrence?.kind === 'interval' && loop.recurrence.anchor_at
1051 ? loop.recurrence.anchor_at
1052 : new Date().toISOString();
1053 const dueAt =
1054 typeof body.due_at === 'string' && body.due_at.trim()
1055 ? body.due_at.trim()
1056 : occurrenceAt;
1057 const title =
1058 typeof body.title_override === 'string' && body.title_override.trim()
1059 ? body.title_override.trim()
1060 : loop.title;
1061
1062 const taskIdResult = computeMaterializeTaskId(loopId, occurrenceKey);
1063 if (!taskIdResult.ok) {
1064 return refuse(400, 'TASK_MATERIALIZE_INVALID', 'computed task_id exceeds limits');
1065 }
1066
1067 const now = new Date().toISOString();
1068 const instanceDraft = {
1069 schema: 'knowtation.task/v0',
1070 task_id: taskIdResult.taskId,
1071 kind: loop.kind,
1072 scope: loop.scope,
1073 status: 'pending',
1074 title,
1075 workspace_id: loop.workspace_id,
1076 due_at: dueAt,
1077 assignee_ref: null,
1078 assigner_ref: null,
1079 run_ref: null,
1080 loop_ref: loopId,
1081 occurrence_key: occurrenceKey,
1082 occurrence_at: occurrenceAt,
1083 series_status_snapshot: loop.status,
1084 skip_reason: null,
1085 artifact_links: [],
1086 created: now,
1087 updated: now,
1088 truncated: false,
1089 };
1090
1091 const validated = validateTaskRecord(instanceDraft);
1092 if (!validated.ok) {
1093 return refuse(400, 'TASK_MATERIALIZE_INVALID', validated.reason);
1094 }
1095
1096 const loopBaseStateId = baseStateId || loopStateId(taskLoopForClient(loop));
1097 const proposalBody = JSON.stringify(
1098 {
1099 proposal_kind: 'task_instance_materialize',
1100 loop_id: loopId,
1101 occurrence_key: occurrenceKey,
1102 task: validated.task,
1103 },
1104 null,
1105 2,
1106 );
1107
1108 const proposal = await createProposalRecord(input, {
1109 path: taskProposalMirrorPath('pending'),
1110 body: proposalBody,
1111 frontmatter: {
1112 type: 'task_instance_proposal',
1113 loop_id: loopId,
1114 task_id: taskIdResult.taskId,
1115 proposal_kind: 'task_instance_materialize',
1116 },
1117 intent: gate.intent,
1118 base_state_id: loopBaseStateId,
1119 source: TASK_PROPOSAL_SOURCE,
1120 vault_id: input.vaultId,
1121 proposed_by: typeof input.userId === 'string' && input.userId.trim() ? input.userId.trim() : undefined,
1122 review_queue: TASK_REVIEW_QUEUE,
1123 ...(gate.externalRef ? { external_ref: gate.externalRef } : {}),
1124 task_meta: {
1125 record_kind: 'task_instance',
1126 proposal_kind: 'task_instance_materialize',
1127 task_id: taskIdResult.taskId,
1128 loop_id: loopId,
1129 occurrence_key: occurrenceKey,
1130 },
1131 });
1132
1133 updateProposalPath(input.dataDir, proposal.proposal_id);
1134
1135 return {
1136 ok: true,
1137 payload: {
1138 schema: TASK_INSTANCE_PROPOSAL_SCHEMA,
1139 proposal_id: proposal.proposal_id,
1140 proposal_kind: 'task_instance_materialize',
1141 loop_id: loopId,
1142 task_id: taskIdResult.taskId,
1143 occurrence_key: occurrenceKey,
1144 base_state_id: loopBaseStateId,
1145 scope: loop.scope,
1146 auto_approvable: false,
1147 status: 'proposed',
1148 review_queue: TASK_REVIEW_QUEUE,
1149 },
1150 };
1151 }
1152
1153 /**
1154 * @param {string} dataDir
1155 * @param {string} proposalId
1156 */
1157 function updateProposalPath(dataDir, proposalId) {
1158 const fp = path.join(dataDir, 'hub_proposals.json');
1159 if (!fs.existsSync(fp)) return;
1160 const all = JSON.parse(fs.readFileSync(fp, 'utf8'));
1161 const idx = all.findIndex((p) => p.proposal_id === proposalId);
1162 if (idx >= 0) {
1163 all[idx].path = taskProposalMirrorPath(proposalId);
1164 fs.writeFileSync(fp, JSON.stringify(all, null, 2), 'utf8');
1165 }
1166 }
1167
1168 /**
1169 * @param {string} proposalId
1170 * @param {string} proposalKind
1171 * @param {string|null} taskId
1172 * @param {string|null} loopId
1173 * @param {string} baseStateId
1174 * @param {TaskScope} scope
1175 */
1176 function buildTaskProposalEnvelope(proposalId, proposalKind, taskId, loopId, baseStateId, scope) {
1177 return {
1178 ok: true,
1179 payload: {
1180 schema: TASK_PROPOSAL_SCHEMA,
1181 proposal_id: proposalId,
1182 proposal_kind: proposalKind,
1183 task_id: taskId,
1184 loop_id: loopId,
1185 base_state_id: baseStateId,
1186 scope,
1187 auto_approvable: false,
1188 status: 'proposed',
1189 review_queue: TASK_REVIEW_QUEUE,
1190 },
1191 };
1192 }
1193
1194 /**
1195 * Approve-time authoritative re-check for task proposals.
1196 *
1197 * @param {string} dataDir
1198 * @param {object} proposal
1199 */
1200 export function precheckApprovedTaskProposal(dataDir, proposal) {
1201 let parsed;
1202 try {
1203 parsed = JSON.parse(typeof proposal.body === 'string' ? proposal.body : '');
1204 } catch {
1205 return refuse(400, 'TASK_DRAFT_INVALID', 'task proposal body is not valid JSON');
1206 }
1207
1208 const meta = proposal.task_meta && typeof proposal.task_meta === 'object' ? proposal.task_meta : {};
1209 const proposalKind = meta.proposal_kind || parsed.proposal_kind;
1210 const vaultId =
1211 typeof proposal.vault_id === 'string' && proposal.vault_id.trim() ? proposal.vault_id.trim() : 'default';
1212 const baseStateId = typeof proposal.base_state_id === 'string' ? proposal.base_state_id : '';
1213
1214 const visibleScopes = new Set(['personal', 'project', 'org']);
1215
1216 if (proposalKind === 'task_create') {
1217 const validated = validateTaskRecord(parsed.task);
1218 if (!validated.ok) return refuse(400, 'TASK_DRAFT_INVALID', validated.reason);
1219 const store = loadFlowStore(dataDir);
1220 const vault = store.vaults[vaultId];
1221 const exists = (vault?.tasks ?? []).some((t) => t.task_id === validated.task.task_id);
1222 if (exists) return refuse(409, 'TASK_LINEAGE_CONFLICT', 'task_id already exists');
1223 return { ok: true, vaultId, proposalKind, parsed, task: validated.task };
1224 }
1225
1226 if (proposalKind === 'task_status_update' || proposalKind === 'task_assign' || proposalKind === 'task_artifact_link') {
1227 const taskId = parsed.task_id;
1228 const existing = getTask(dataDir, vaultId, taskId, { visibleScopes });
1229 if (!existing) return refuse(409, 'TASK_LINEAGE_CONFLICT', 'task disappeared before approve');
1230 const serverStateId = taskStateId(taskForClient(existing));
1231 if (serverStateId !== baseStateId) {
1232 return refuse(409, 'TASK_LINEAGE_CONFLICT', 'task changed since proposal was based');
1233 }
1234 return { ok: true, vaultId, proposalKind, parsed, existing };
1235 }
1236
1237 if (proposalKind === 'task_loop_create') {
1238 const validated = validateTaskLoopRecord(parsed.loop);
1239 if (!validated.ok) return refuse(400, 'TASK_DRAFT_INVALID', validated.reason);
1240 const store = loadFlowStore(dataDir);
1241 const vault = store.vaults[vaultId];
1242 const exists = (vault?.task_loops ?? []).some((l) => l.loop_id === validated.loop.loop_id);
1243 if (exists) return refuse(409, 'TASK_LOOP_LINEAGE_CONFLICT', 'loop_id already exists');
1244 return { ok: true, vaultId, proposalKind, parsed, loop: validated.loop };
1245 }
1246
1247 if (proposalKind === 'task_loop_pause' || proposalKind === 'task_loop_cancel') {
1248 const loopId = parsed.loop_id;
1249 const existing = getTaskLoop(dataDir, vaultId, loopId, { visibleScopes });
1250 if (!existing) return refuse(409, 'TASK_LOOP_LINEAGE_CONFLICT', 'loop disappeared before approve');
1251 const serverStateId = loopStateId(taskLoopForClient(existing));
1252 if (serverStateId !== baseStateId) {
1253 return refuse(409, 'TASK_LOOP_LINEAGE_CONFLICT', 'loop changed since proposal was based');
1254 }
1255 return { ok: true, vaultId, proposalKind, parsed, existing };
1256 }
1257
1258 if (proposalKind === 'task_instance_materialize') {
1259 const loopId = parsed.loop_id;
1260 const loop = getTaskLoop(dataDir, vaultId, loopId, { visibleScopes });
1261 if (!loop) return refuse(409, 'TASK_LOOP_LINEAGE_CONFLICT', 'loop disappeared before approve');
1262 if (loop.status !== 'active') {
1263 return refuse(409, 'TASK_LOOP_NOT_ACTIVE', 'loop is not active');
1264 }
1265 const serverLoopStateId = loopStateId(taskLoopForClient(loop));
1266 if (baseStateId && serverLoopStateId !== baseStateId) {
1267 return refuse(409, 'TASK_LOOP_LINEAGE_CONFLICT', 'loop changed since materialize was based');
1268 }
1269 const keys = existingOccurrenceKeys(dataDir, vaultId, loopId);
1270 if (keys.has(parsed.occurrence_key)) {
1271 return refuse(409, 'TASK_OCCURRENCE_EXISTS', 'occurrence already materialized');
1272 }
1273 const validated = validateTaskRecord(parsed.task);
1274 if (!validated.ok) return refuse(400, 'TASK_MATERIALIZE_INVALID', validated.reason);
1275 return { ok: true, vaultId, proposalKind, parsed, task: validated.task, loop };
1276 }
1277
1278 return refuse(400, 'TASK_DRAFT_INVALID', 'unknown task proposal_kind');
1279 }
1280
1281 /**
1282 * Apply a pre-checked task proposal into hub_flow_store.json.
1283 *
1284 * @param {string} dataDir
1285 * @param {object} applyCtx - output of precheckApprovedTaskProposal when ok
1286 */
1287 export function reconcileApprovedTaskProposal(dataDir, applyCtx) {
1288 const store = loadFlowStore(dataDir);
1289 const vaultId = applyCtx.vaultId;
1290 if (!store.vaults[vaultId]) {
1291 store.vaults[vaultId] = {
1292 flows: [],
1293 steps: [],
1294 runs: [],
1295 candidates: [],
1296 projections: [],
1297 tasks: [],
1298 task_loops: [],
1299 orchestrator_graphs: [],
1300 };
1301 }
1302 const vault = store.vaults[vaultId];
1303 if (!Array.isArray(vault.tasks)) vault.tasks = [];
1304 if (!Array.isArray(vault.task_loops)) vault.task_loops = [];
1305
1306 const now = new Date().toISOString();
1307 const kind = applyCtx.proposalKind;
1308
1309 if (kind === 'task_create' || kind === 'task_instance_materialize') {
1310 const task = { ...applyCtx.task, updated: now };
1311 if (kind === 'task_create') {
1312 task.created = now;
1313 }
1314 vault.tasks.push(task);
1315 saveFlowStore(dataDir, store);
1316 return { applied: true, task_id: task.task_id };
1317 }
1318
1319 if (kind === 'task_status_update') {
1320 const idx = vault.tasks.findIndex((t) => t.task_id === applyCtx.parsed.task_id);
1321 if (idx < 0) throw new Error('task missing at apply');
1322 vault.tasks[idx] = {
1323 ...vault.tasks[idx],
1324 status: applyCtx.parsed.status,
1325 skip_reason: applyCtx.parsed.skip_reason ?? null,
1326 updated: now,
1327 };
1328 saveFlowStore(dataDir, store);
1329 return { applied: true, task_id: applyCtx.parsed.task_id };
1330 }
1331
1332 if (kind === 'task_assign') {
1333 const idx = vault.tasks.findIndex((t) => t.task_id === applyCtx.parsed.task_id);
1334 if (idx < 0) throw new Error('task missing at apply');
1335 vault.tasks[idx] = {
1336 ...vault.tasks[idx],
1337 assignee_ref: applyCtx.parsed.assignee_ref ?? null,
1338 assigner_ref: applyCtx.parsed.assigner_ref ?? null,
1339 updated: now,
1340 };
1341 saveFlowStore(dataDir, store);
1342 return { applied: true, task_id: applyCtx.parsed.task_id };
1343 }
1344
1345 if (kind === 'task_artifact_link') {
1346 const idx = vault.tasks.findIndex((t) => t.task_id === applyCtx.parsed.task_id);
1347 if (idx < 0) throw new Error('task missing at apply');
1348 const links = [...(vault.tasks[idx].artifact_links ?? [])];
1349 links.push(applyCtx.parsed.artifact_link);
1350 vault.tasks[idx] = {
1351 ...vault.tasks[idx],
1352 artifact_links: links.slice(0, MAX_ARTIFACT_LINKS),
1353 truncated: links.length > MAX_ARTIFACT_LINKS,
1354 updated: now,
1355 };
1356 saveFlowStore(dataDir, store);
1357 return { applied: true, task_id: applyCtx.parsed.task_id };
1358 }
1359
1360 if (kind === 'task_loop_create') {
1361 const loop = { ...applyCtx.loop, created: now, updated: now };
1362 vault.task_loops.push(loop);
1363 saveFlowStore(dataDir, store);
1364 return { applied: true, loop_id: loop.loop_id };
1365 }
1366
1367 if (kind === 'task_loop_pause') {
1368 const idx = vault.task_loops.findIndex((l) => l.loop_id === applyCtx.parsed.loop_id);
1369 if (idx < 0) throw new Error('loop missing at apply');
1370 vault.task_loops[idx] = { ...vault.task_loops[idx], status: 'paused', updated: now };
1371 saveFlowStore(dataDir, store);
1372 return { applied: true, loop_id: applyCtx.parsed.loop_id };
1373 }
1374
1375 if (kind === 'task_loop_cancel') {
1376 const loopId = applyCtx.parsed.loop_id;
1377 const loopIdx = vault.task_loops.findIndex((l) => l.loop_id === loopId);
1378 if (loopIdx < 0) throw new Error('loop missing at apply');
1379 vault.task_loops[loopIdx] = { ...vault.task_loops[loopIdx], status: 'cancelled', updated: now };
1380
1381 /** @type {string[]} */
1382 const cascadeTaskIds = [];
1383 for (let i = 0; i < vault.tasks.length; i += 1) {
1384 const task = vault.tasks[i];
1385 if (task.loop_ref !== loopId) continue;
1386 if (!PENDING_INSTANCE_STATUSES.includes(task.status)) continue;
1387 vault.tasks[i] = {
1388 ...task,
1389 status: 'cancelled',
1390 skip_reason: 'series_cancelled',
1391 updated: now,
1392 };
1393 cascadeTaskIds.push(task.task_id);
1394 }
1395
1396 saveFlowStore(dataDir, store);
1397 return { applied: true, loop_id: loopId, cascade_task_ids: cascadeTaskIds };
1398 }
1399
1400 throw new Error(`unsupported task proposal_kind at apply: ${kind}`);
1401 }
1402
1403 export { TASK_ID_RE, LOOP_ID_RE, TASK_KINDS };
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago