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