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