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