task-write.mjs
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