agent-relay-server 0.107.0 → 0.109.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/openapi.json +589 -1
- package/package.json +4 -4
- package/public/assets/MessageBubble-XgN61quY.js +4 -0
- package/public/assets/{MessageBubble-PEdvVh_m.js.map → MessageBubble-XgN61quY.js.map} +1 -1
- package/public/assets/PlanGraphPanel-LY-msOsr.js +2 -0
- package/public/assets/PlanGraphPanel-LY-msOsr.js.map +1 -0
- package/public/assets/activity-D0EotVU9.js +2 -0
- package/public/assets/activity-D0EotVU9.js.map +1 -0
- package/public/assets/{agent-profiles-CuTQtmV6.js → agent-profiles-CAGvryb9.js} +2 -2
- package/public/assets/{agent-profiles-CuTQtmV6.js.map → agent-profiles-CAGvryb9.js.map} +1 -1
- package/public/assets/agents-CXXZ_sU9.js +2 -0
- package/public/assets/{agents-CgRFJyks.js.map → agents-CXXZ_sU9.js.map} +1 -1
- package/public/assets/{analytics-C3OTFIXo.js → analytics-Q6Dl_CE9.js} +3 -3
- package/public/assets/{analytics-C3OTFIXo.js.map → analytics-Q6Dl_CE9.js.map} +1 -1
- package/public/assets/{automation-CH9YGYoE.js → automation-zig0cp23.js} +2 -2
- package/public/assets/{automation-CH9YGYoE.js.map → automation-zig0cp23.js.map} +1 -1
- package/public/assets/{badge-D8fABzMs.js → badge-kdQDWO6e.js} +2 -2
- package/public/assets/{badge-D8fABzMs.js.map → badge-kdQDWO6e.js.map} +1 -1
- package/public/assets/{branch-state-badge-CcLZ_u86.js → branch-state-badge-BRjUWyMT.js} +2 -2
- package/public/assets/{branch-state-badge-CcLZ_u86.js.map → branch-state-badge-BRjUWyMT.js.map} +1 -1
- package/public/assets/{button-KASZcscA.js → button-BKFvNXa3.js} +2 -2
- package/public/assets/{button-KASZcscA.js.map → button-BKFvNXa3.js.map} +1 -1
- package/public/assets/card-B0y1i_2D.js +2 -0
- package/public/assets/{card-Cds0MWME.js.map → card-B0y1i_2D.js.map} +1 -1
- package/public/assets/{channels-CcSnLzCG.js → channels-DOYB1iiA.js} +2 -2
- package/public/assets/{channels-CcSnLzCG.js.map → channels-DOYB1iiA.js.map} +1 -1
- package/public/assets/chat-C3QlsFBd.js +2 -0
- package/public/assets/{chat-BLcm2Aht.js.map → chat-C3QlsFBd.js.map} +1 -1
- package/public/assets/{connectors-DbPOlQhL.js → connectors-C5CeK-2M.js} +2 -2
- package/public/assets/{connectors-DbPOlQhL.js.map → connectors-C5CeK-2M.js.map} +1 -1
- package/public/assets/{copy-button-d9DFNX9v.js → copy-button-aLaiAkv2.js} +2 -2
- package/public/assets/{copy-button-d9DFNX9v.js.map → copy-button-aLaiAkv2.js.map} +1 -1
- package/public/assets/display-_5kFrHJh.js +3 -0
- package/public/assets/display-_5kFrHJh.js.map +1 -0
- package/public/assets/{dist-DOAjFOS4.js → dist-BDYU37NZ.js} +2 -2
- package/public/assets/{dist-DOAjFOS4.js.map → dist-BDYU37NZ.js.map} +1 -1
- package/public/assets/dist-BnfqT2cl.js +2 -0
- package/public/assets/{dist-DC-TqTz2.js.map → dist-BnfqT2cl.js.map} +1 -1
- package/public/assets/dist-CF28Y5wI.js +2 -0
- package/public/assets/{dist-CBVZk9U8.js.map → dist-CF28Y5wI.js.map} +1 -1
- package/public/assets/{dist-DTXKbk2K.js → dist-DDh8a_SK.js} +2 -2
- package/public/assets/{dist-DTXKbk2K.js.map → dist-DDh8a_SK.js.map} +1 -1
- package/public/assets/{dist-BbUJ1Vht.js → dist-DKoRwEZq.js} +2 -2
- package/public/assets/{dist-BbUJ1Vht.js.map → dist-DKoRwEZq.js.map} +1 -1
- package/public/assets/{dist-UhEAFdvZ.js → dist-DQPz52Zx.js} +2 -2
- package/public/assets/{dist-UhEAFdvZ.js.map → dist-DQPz52Zx.js.map} +1 -1
- package/public/assets/{dist-CgqfdHSt.js → dist-D_qcpfbI.js} +2 -2
- package/public/assets/{dist-CgqfdHSt.js.map → dist-D_qcpfbI.js.map} +1 -1
- package/public/assets/{dist-DybScJmm.js → dist-DnyQI0qt.js} +2 -2
- package/public/assets/{dist-DybScJmm.js.map → dist-DnyQI0qt.js.map} +1 -1
- package/public/assets/{dist-BwwlmyBq.js → dist-EO8jbi2F.js} +2 -2
- package/public/assets/{dist-BwwlmyBq.js.map → dist-EO8jbi2F.js.map} +1 -1
- package/public/assets/{dist-BlZvUZZB.js → dist-avJPT3qF.js} +2 -2
- package/public/assets/{dist-BlZvUZZB.js.map → dist-avJPT3qF.js.map} +1 -1
- package/public/assets/dist-cyag2tir.js +2 -0
- package/public/assets/{dist-DreO5AcZ.js.map → dist-cyag2tir.js.map} +1 -1
- package/public/assets/{es2015-DUjFyjDp.js → es2015-C9FSEGd7.js} +2 -2
- package/public/assets/{es2015-DUjFyjDp.js.map → es2015-C9FSEGd7.js.map} +1 -1
- package/public/assets/{formatted-body-impl-BJTthVHg.js → formatted-body-impl-DCQSVyWB.js} +2 -2
- package/public/assets/{formatted-body-impl-BJTthVHg.js.map → formatted-body-impl-DCQSVyWB.js.map} +1 -1
- package/public/assets/index-D6hQkte1.css +2 -0
- package/public/assets/index-RCbIyk26.js +23 -0
- package/public/assets/index-RCbIyk26.js.map +1 -0
- package/public/assets/{input-CtHfLn1i.js → input-DfN6sUbX.js} +2 -2
- package/public/assets/{input-CtHfLn1i.js.map → input-DfN6sUbX.js.map} +1 -1
- package/public/assets/{insights-DklrIhb6.js → insights-CsLs6NbL.js} +2 -2
- package/public/assets/{insights-DklrIhb6.js.map → insights-CsLs6NbL.js.map} +1 -1
- package/public/assets/{integrations-hkSW8CzM.js → integrations-CztQfrI7.js} +2 -2
- package/public/assets/{integrations-hkSW8CzM.js.map → integrations-CztQfrI7.js.map} +1 -1
- package/public/assets/{lib-l-JkMVR9.js → lib-DRRHEqRZ.js} +2 -2
- package/public/assets/{lib-l-JkMVR9.js.map → lib-DRRHEqRZ.js.map} +1 -1
- package/public/assets/{lucide-react-C4eXYCwx.js → lucide-react-DmbfpRvJ.js} +2 -2
- package/public/assets/{lucide-react-C4eXYCwx.js.map → lucide-react-DmbfpRvJ.js.map} +1 -1
- package/public/assets/{maintenance-FAAMGpuZ.js → maintenance-BN5F0Lnc.js} +2 -2
- package/public/assets/{maintenance-FAAMGpuZ.js.map → maintenance-BN5F0Lnc.js.map} +1 -1
- package/public/assets/managed-agents-R_LI9qS5.js +4 -0
- package/public/assets/{managed-agents-CVca-s3t.js.map → managed-agents-R_LI9qS5.js.map} +1 -1
- package/public/assets/markdown-preview-impl-DQlmK_ev.js +2 -0
- package/public/assets/{markdown-preview-impl-BPW4OQo9.js.map → markdown-preview-impl-DQlmK_ev.js.map} +1 -1
- package/public/assets/memory-kn4bgMXC.js +2 -0
- package/public/assets/{memory-BzuDTHC5.js.map → memory-kn4bgMXC.js.map} +1 -1
- package/public/assets/{messages-_W24PNvT.js → messages-Dv3Tv7zC.js} +2 -2
- package/public/assets/{messages-_W24PNvT.js.map → messages-Dv3Tv7zC.js.map} +1 -1
- package/public/assets/{orchestrators-Du6I_n8i.js → orchestrators-DcNLRMzk.js} +2 -2
- package/public/assets/{orchestrators-Du6I_n8i.js.map → orchestrators-DcNLRMzk.js.map} +1 -1
- package/public/assets/overview-BOLzPSSC.js +2 -0
- package/public/assets/{overview-Dyvm1qpT.js.map → overview-BOLzPSSC.js.map} +1 -1
- package/public/assets/{pairs-B8x1RCNd.js → pairs-B4r0egpx.js} +2 -2
- package/public/assets/{pairs-B8x1RCNd.js.map → pairs-B4r0egpx.js.map} +1 -1
- package/public/assets/projects-cOeuiMjl.js +2 -0
- package/public/assets/{projects-vt63bwHN.js.map → projects-cOeuiMjl.js.map} +1 -1
- package/public/assets/prompt-settings-E7-6Osj4.js +2 -0
- package/public/assets/{prompt-settings-xVXmvxB5.js.map → prompt-settings-E7-6Osj4.js.map} +1 -1
- package/public/assets/{react-dom-BMF4hNzG.js → react-dom-jQ4Vs380.js} +2 -2
- package/public/assets/{react-dom-BMF4hNzG.js.map → react-dom-jQ4Vs380.js.map} +1 -1
- package/public/assets/registry-CTN3jOnb.js +2 -0
- package/public/assets/{registry-ypiICauZ.js.map → registry-CTN3jOnb.js.map} +1 -1
- package/public/assets/{security-BfOQ6T5r.js → security-BctIcBZb.js} +2 -2
- package/public/assets/{security-BfOQ6T5r.js.map → security-BctIcBZb.js.map} +1 -1
- package/public/assets/{select-Dx8Ro-A3.js → select-BndvVb0A.js} +2 -2
- package/public/assets/{select-Dx8Ro-A3.js.map → select-BndvVb0A.js.map} +1 -1
- package/public/assets/settings-B_tRq-P4.js +6 -0
- package/public/assets/{settings-87clTx4N.js.map → settings-B_tRq-P4.js.map} +1 -1
- package/public/assets/store-DDWg67Pg.js +9 -0
- package/public/assets/store-DDWg67Pg.js.map +1 -0
- package/public/assets/tasks-BgTTxVET.js +2 -0
- package/public/assets/tasks-BgTTxVET.js.map +1 -0
- package/public/assets/teams-DJiLBEgF.js +2 -0
- package/public/assets/{teams-DJWp4AKi.js.map → teams-DJiLBEgF.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-DgMuRAe_.js → terminal-viewer-impl-BLdiyCNR.js} +3 -3
- package/public/assets/{terminal-viewer-impl-DgMuRAe_.js.map → terminal-viewer-impl-BLdiyCNR.js.map} +1 -1
- package/public/assets/types-DSwhOFcJ.js +2 -0
- package/public/assets/types-DSwhOFcJ.js.map +1 -0
- package/public/assets/{use-keyboard-viewport-RYMALvu1.js → use-keyboard-viewport-Dw2CHZ50.js} +2 -2
- package/public/assets/{use-keyboard-viewport-RYMALvu1.js.map → use-keyboard-viewport-Dw2CHZ50.js.map} +1 -1
- package/public/assets/user-avatar-rt59cEsc.js +2 -0
- package/public/assets/{user-avatar-CfAUS3PZ.js.map → user-avatar-rt59cEsc.js.map} +1 -1
- package/public/assets/{work-queue-D-Otxd9u.js → work-queue-BYRdBuLR.js} +2 -2
- package/public/assets/{work-queue-D-Otxd9u.js.map → work-queue-BYRdBuLR.js.map} +1 -1
- package/public/assets/workspaces-DCRT3l9H.js +3 -0
- package/public/assets/workspaces-DCRT3l9H.js.map +1 -0
- package/public/index.html +23 -23
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +6 -0
- package/src/branch-landed.ts +17 -3
- package/src/config-store.ts +12 -1
- package/src/db/agents.ts +3 -2
- package/src/db/continuations.ts +13 -3
- package/src/db/index.ts +3 -0
- package/src/db/issue-lifecycle.ts +12 -0
- package/src/db/mappers.ts +17 -0
- package/src/db/messages.ts +9 -4
- package/src/db/migrations.ts +112 -2
- package/src/db/notification-subscriptions.ts +1 -1
- package/src/db/prompt-templates.ts +19 -0
- package/src/db/schema.ts +74 -2
- package/src/db/stats.ts +3 -2
- package/src/db/task-comment-through.ts +112 -0
- package/src/db/task-core.ts +14 -1
- package/src/db/task-entity.ts +629 -0
- package/src/db/task-maintenance.ts +4 -3
- package/src/db/task-memory.ts +180 -0
- package/src/db/tasks.ts +24 -10
- package/src/issue-tracker/adapter.ts +15 -0
- package/src/issue-tracker/github.ts +40 -2
- package/src/issue-tracker/index.ts +1 -0
- package/src/mcp/index.ts +12 -1
- package/src/mcp/tools-spawn.ts +4 -0
- package/src/mcp-dispatch-tools.ts +193 -0
- package/src/mcp-notification-subscription-tools.ts +62 -4
- package/src/mcp-plan-tools.ts +30 -3
- package/src/mcp-release-tools.ts +33 -0
- package/src/mcp-task-memory-tools.ts +115 -0
- package/src/mcp-task-tools.ts +232 -0
- package/src/mcp-task-weave-tools.ts +66 -0
- package/src/notification-router.ts +24 -2
- package/src/notification-rules.ts +4 -0
- package/src/notification-types.ts +23 -0
- package/src/prompt-resolver.ts +66 -3
- package/src/routes/agents-spawn.ts +10 -0
- package/src/routes/index.ts +17 -1
- package/src/routes/messages.ts +6 -6
- package/src/routes/plans.ts +10 -2
- package/src/routes/tasks.ts +335 -2
- package/src/routing-policy-store.ts +147 -2
- package/src/security.ts +4 -0
- package/src/self-resume.ts +13 -2
- package/src/services/issue-lifecycle.ts +9 -1
- package/src/services/plan-graph.ts +107 -9
- package/src/services/release-readiness.ts +142 -0
- package/src/services/send-message.ts +31 -0
- package/src/services/spawn-agent.ts +81 -4
- package/src/services/task-context-hydration.ts +361 -0
- package/src/services/task-entity.ts +149 -0
- package/src/services/task-issue-weave.ts +558 -0
- package/src/services/task-lifecycle.ts +465 -0
- package/src/services/task-plan-view.ts +178 -0
- package/src/services/task-semantic-events.ts +137 -0
- package/src/services/transient-agent-reaper.ts +55 -22
- package/src/spawn-targets.ts +27 -2
- package/src/task-authz.ts +52 -0
- package/src/task-dispatcher.ts +461 -0
- package/src/upgrade-install-verify.ts +20 -1
- package/public/assets/MessageBubble-PEdvVh_m.js +0 -4
- package/public/assets/PlanGraphPanel-DSts7Rtq.js +0 -2
- package/public/assets/PlanGraphPanel-DSts7Rtq.js.map +0 -1
- package/public/assets/activity-BcYf8YNO.js +0 -2
- package/public/assets/activity-BcYf8YNO.js.map +0 -1
- package/public/assets/agents-CgRFJyks.js +0 -2
- package/public/assets/card-Cds0MWME.js +0 -2
- package/public/assets/chat-BLcm2Aht.js +0 -2
- package/public/assets/display-BWi8DQCp.js +0 -3
- package/public/assets/display-BWi8DQCp.js.map +0 -1
- package/public/assets/dist-CBVZk9U8.js +0 -2
- package/public/assets/dist-DC-TqTz2.js +0 -2
- package/public/assets/dist-DreO5AcZ.js +0 -2
- package/public/assets/index-3ydeNO0c.js +0 -23
- package/public/assets/index-3ydeNO0c.js.map +0 -1
- package/public/assets/index-BjjzI2LT.css +0 -2
- package/public/assets/managed-agents-CVca-s3t.js +0 -4
- package/public/assets/markdown-preview-impl-BPW4OQo9.js +0 -2
- package/public/assets/memory-BzuDTHC5.js +0 -2
- package/public/assets/overview-Dyvm1qpT.js +0 -2
- package/public/assets/projects-vt63bwHN.js +0 -2
- package/public/assets/prompt-settings-xVXmvxB5.js +0 -2
- package/public/assets/registry-ypiICauZ.js +0 -2
- package/public/assets/settings-87clTx4N.js +0 -6
- package/public/assets/store-DUfrGvOE.js +0 -9
- package/public/assets/store-DUfrGvOE.js.map +0 -1
- package/public/assets/tasks-1Kvp53wh.js +0 -2
- package/public/assets/tasks-1Kvp53wh.js.map +0 -1
- package/public/assets/teams-DJWp4AKi.js +0 -2
- package/public/assets/types-DorDDvEb.js +0 -2
- package/public/assets/types-DorDDvEb.js.map +0 -1
- package/public/assets/user-avatar-CfAUS3PZ.js +0 -2
- package/public/assets/workspaces-CIsQqQby.js +0 -3
- package/public/assets/workspaces-CIsQqQby.js.map +0 -1
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// #838 — Spawn-with-task: relay-hydrated task agents (epic #830, the unlock).
|
|
2
|
+
//
|
|
3
|
+
// When a coordinator spawns a worker with `relay_spawn_agent({ taskId, steer? })`, relay
|
|
4
|
+
// assembles the worker's FIRST-TURN context FROM the durable Task (#834) instead of the
|
|
5
|
+
// coordinator hand-writing a 400-word brief. The worker boots already knowing: intent,
|
|
6
|
+
// the linked-issue spec/acceptance (read server-side ONCE via the issue-ref → tracker path,
|
|
7
|
+
// #833 seam), the recorded decisions/rules, the event history (who-did-what), the current
|
|
8
|
+
// attribution, and the latest one-line steer. The worker does NOT use GitHub MCP to read the
|
|
9
|
+
// issue or burn context reconstructing background — the Task (relay) does that here.
|
|
10
|
+
//
|
|
11
|
+
// Two halves, deliberately split so the renderer stays a PURE, unit-testable builder:
|
|
12
|
+
// - `renderTaskHydration(facets)` — pure: facets → continuation-style brief string.
|
|
13
|
+
// - `hydrateTaskContext(input)` — assembler: reads the Task + events + linked-issue spec,
|
|
14
|
+
// classifies the ledger, then calls the pure renderer.
|
|
15
|
+
// `projectTaskHydration(...)` then maps the brief onto the provider-contract spawn channel
|
|
16
|
+
// through the SAME controlled systemPromptAppend / prompt path existing spawn injection uses.
|
|
17
|
+
import { getManifest } from "agent-relay-providers";
|
|
18
|
+
import { getTaskDetail, listTaskEvents, readTaskMemoryEnvelope, ValidationError } from "../db";
|
|
19
|
+
import { getIssueRef } from "../db/issue-refs";
|
|
20
|
+
import { resolveIssueTrackerAdapter, type IssueTrackerIssueDetail } from "../issue-tracker";
|
|
21
|
+
import { renderTemplate } from "../prompt-resolver";
|
|
22
|
+
import { renderContinuation } from "../self-resume";
|
|
23
|
+
import type { SpawnProvider } from "agent-relay-sdk";
|
|
24
|
+
import type { ContinuationDecisionEntry, ContinuationEnvelope, ContinuationLedgerEntry, TaskDetail, TaskEvent, TaskIssueLink } from "../types";
|
|
25
|
+
import type { TaskMemoryEnvelope } from "../db/task-memory";
|
|
26
|
+
|
|
27
|
+
/** A linked issue's spec resolved server-side (body may be absent if the fetch failed). */
|
|
28
|
+
export interface ResolvedIssueSpec {
|
|
29
|
+
link: TaskIssueLink;
|
|
30
|
+
/** Trimmed issue body (intent/spec/acceptance) when the read succeeded. */
|
|
31
|
+
body?: string;
|
|
32
|
+
/** Set when the server-side read failed — the brief notes the spec is unavailable. */
|
|
33
|
+
unavailable?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Everything the PURE renderer needs — already resolved, no I/O. */
|
|
37
|
+
export interface TaskHydrationFacets {
|
|
38
|
+
taskId: number;
|
|
39
|
+
title: string;
|
|
40
|
+
status: string;
|
|
41
|
+
stage: string;
|
|
42
|
+
owner?: string;
|
|
43
|
+
intent: string;
|
|
44
|
+
doneWhen?: string;
|
|
45
|
+
issues: ResolvedIssueSpec[];
|
|
46
|
+
/** Decisions/rules: discussion + milestone write-throughs and steers (the narrative ledger). */
|
|
47
|
+
decisions: TaskEvent[];
|
|
48
|
+
/** Recent who-did-what event stream. */
|
|
49
|
+
history: TaskEvent[];
|
|
50
|
+
steer?: string;
|
|
51
|
+
/** #832 — task-bound memory hydration, using the self-resume continuation envelope renderer. */
|
|
52
|
+
memory?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// How many trailing history events to surface so the brief stays bounded (the full ledger
|
|
56
|
+
// lives on the Task; the worker can read it via relay_task tools if it needs more).
|
|
57
|
+
const HISTORY_LIMIT = 20;
|
|
58
|
+
// JS-level guard for ASYNC issue fetchers (a future REST adapter, a test stub). The production
|
|
59
|
+
// `gh` path is synchronous and bounded by the subprocess timeout in the GitHub adapter (a JS race
|
|
60
|
+
// can't preempt a blocking spawnSync); this race additionally bounds any async fetcher so a hung
|
|
61
|
+
// read degrades to partial "spec unavailable" instead of stalling the spawn.
|
|
62
|
+
const ISSUE_FETCH_TIMEOUT_MS = 12_000;
|
|
63
|
+
// Issue body excerpt cap — keep the spec readable without dumping a novel into every spawn.
|
|
64
|
+
const ISSUE_BODY_LIMIT = 8_000;
|
|
65
|
+
const TASK_MEMORY_WORKING_STATE_LIMIT = 6_000;
|
|
66
|
+
const TASK_MEMORY_ENTRY_LIMIT = 500;
|
|
67
|
+
const TASK_MEMORY_LEDGER_LIMIT = 12;
|
|
68
|
+
const TASK_MEMORY_DECISION_LIMIT = 12;
|
|
69
|
+
const TASK_MEMORY_SECTION_LIMIT = 16_000;
|
|
70
|
+
|
|
71
|
+
// Event types that represent a recorded decision/rule rather than raw execution telemetry:
|
|
72
|
+
// the #833 write-through receipts (discussion + milestone) plus relay-recorded steers and the
|
|
73
|
+
// blocker rationale captured when a Task is blocked.
|
|
74
|
+
const DECISION_EVENT_TYPES = new Set(["comment.posted", "milestone.posted"]);
|
|
75
|
+
function isDecisionEvent(event: TaskEvent): boolean {
|
|
76
|
+
if (DECISION_EVENT_TYPES.has(event.type)) return true;
|
|
77
|
+
// A `task.updated` that changed the steer (updateDeliverableTask stamps the changed columns
|
|
78
|
+
// in metadata.fields) is a recorded decision.
|
|
79
|
+
if (event.type === "task.updated") {
|
|
80
|
+
const fields = event.metadata?.fields;
|
|
81
|
+
if (Array.isArray(fields) && fields.includes("latest_steer")) return true;
|
|
82
|
+
}
|
|
83
|
+
// A blocked transition carries the human rationale for the block.
|
|
84
|
+
if (event.type === "status:blocked") return true;
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function truncate(value: string, max: number): string {
|
|
89
|
+
if (value.length <= max) return value;
|
|
90
|
+
return `${value.slice(0, max)}\n… (truncated; read the full spec on the Task if needed)`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- Prompt-injection containment (#838 review finding 2) ----------------------------------
|
|
94
|
+
// Task intent, issue bodies, steers, titles and event text are UNTRUSTED (an issue body comes
|
|
95
|
+
// from an external tracker; a steer/milestone is agent-authored). Placed raw in the brief — and
|
|
96
|
+
// for Claude in the instruction-bearing systemPromptAppend — such text could impersonate a
|
|
97
|
+
// trusted section header (`Latest steer:`, a fake report-up/profile block) or otherwise read as
|
|
98
|
+
// instructions. We fence every untrusted free-text block between sentinels the content CANNOT
|
|
99
|
+
// forge (any sentinel-looking substring is stripped first), and the template frames everything
|
|
100
|
+
// between the sentinels as DATA, never instructions. Trusted framing (the "do NOT read the
|
|
101
|
+
// tracker" directive, the section labels, the imperative to act on the steer) stays OUTSIDE.
|
|
102
|
+
export const DATA_FENCE_OPEN = "⟦untrusted-task-data⟧";
|
|
103
|
+
export const DATA_FENCE_CLOSE = "⟦∕untrusted-task-data⟧";
|
|
104
|
+
const FENCE_SENTINEL_RE = /⟦∕?untrusted-task-data⟧/g;
|
|
105
|
+
|
|
106
|
+
function stripSentinels(value: string): string {
|
|
107
|
+
return value.replace(FENCE_SENTINEL_RE, "");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Fence a multi-line untrusted block as DATA. Internal newlines are preserved (safe — the
|
|
111
|
+
* closing sentinel can't be forged), so issue-spec formatting survives. */
|
|
112
|
+
function fence(value: string): string {
|
|
113
|
+
const cleaned = stripSentinels(value).trim();
|
|
114
|
+
return `${DATA_FENCE_OPEN}\n${cleaned || "(empty)"}\n${DATA_FENCE_CLOSE}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Sanitize untrusted text used INLINE (titles, agent ids, event lines): strip sentinels and
|
|
118
|
+
* collapse newlines so a multi-line value can't open a forged section header on a fresh line. */
|
|
119
|
+
function inlineSafe(value: string): string {
|
|
120
|
+
return stripSentinels(value).replace(/[\r\n]+/g, " ⏎ ").trim();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function renderIssue(spec: ResolvedIssueSpec): string {
|
|
124
|
+
const { link } = spec;
|
|
125
|
+
const title = link.title ? ` — ${inlineSafe(link.title)}` : "";
|
|
126
|
+
const header = `- ${inlineSafe(link.repo)}#${inlineSafe(link.externalKey)}${title} (${inlineSafe(link.status)})\n ${inlineSafe(link.url)}`;
|
|
127
|
+
if (spec.unavailable) return `${header}\n (spec unavailable — server-side read failed; the Task intent above still applies)`;
|
|
128
|
+
if (!spec.body) return `${header}\n (no issue body)`;
|
|
129
|
+
return `${header}\n${fence(truncate(spec.body, ISSUE_BODY_LIMIT))}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function renderEvent(event: TaskEvent): string {
|
|
133
|
+
const when = new Date(event.createdAt).toISOString();
|
|
134
|
+
const body = event.body?.trim();
|
|
135
|
+
// Event source/title/body are untrusted — render the whole line inline-safe so an injected
|
|
136
|
+
// milestone/steer body can't break onto a new line that mimics a trusted section header.
|
|
137
|
+
return `- ${when} · ${inlineSafe(event.source)} · ${inlineSafe(event.title)}${body ? `: ${inlineSafe(body)}` : ""}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* PURE builder: assemble the continuation-style first-turn brief from already-resolved facets.
|
|
142
|
+
* Reuses the shared `renderTemplate` util (same DRY seam self-resume's `renderContinuation`
|
|
143
|
+
* uses) so the brief is overridable via the prompt-template store — no forked renderer.
|
|
144
|
+
*/
|
|
145
|
+
export function renderTaskHydration(facets: TaskHydrationFacets): string {
|
|
146
|
+
const issues = facets.issues.length ? facets.issues.map(renderIssue).join("\n") : "- none linked";
|
|
147
|
+
const decisions = facets.decisions.length ? facets.decisions.map(renderEvent).join("\n") : "- none recorded";
|
|
148
|
+
const history = facets.history.length ? facets.history.map(renderEvent).join("\n") : "- none yet";
|
|
149
|
+
const memory = facets.memory ? `\n${fence(facets.memory)}\n` : "";
|
|
150
|
+
return renderTemplate("task.hydration", {
|
|
151
|
+
taskId: String(facets.taskId),
|
|
152
|
+
title: inlineSafe(facets.title),
|
|
153
|
+
status: inlineSafe(facets.status),
|
|
154
|
+
stage: inlineSafe(facets.stage),
|
|
155
|
+
owner: facets.owner ? ` · owner: ${inlineSafe(facets.owner)}` : "",
|
|
156
|
+
intent: fence(facets.intent || "(no intent recorded)"),
|
|
157
|
+
doneWhen: fence(facets.doneWhen || "(no explicit acceptance recorded)"),
|
|
158
|
+
issues,
|
|
159
|
+
decisions,
|
|
160
|
+
history,
|
|
161
|
+
memory,
|
|
162
|
+
steer: fence(facets.steer || "(no steer provided — proceed toward the acceptance above)"),
|
|
163
|
+
}).trimEnd();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function hasTaskMemory(envelope: TaskMemoryEnvelope): boolean {
|
|
167
|
+
return Boolean(envelope.workingState.trim()) || envelope.ledger.length > 0 || envelope.decisions.length > 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function renderTaskMemory(envelope: TaskMemoryEnvelope): string | undefined {
|
|
171
|
+
if (!hasTaskMemory(envelope)) return undefined;
|
|
172
|
+
const rendered = renderContinuation(boundedTaskMemoryEnvelope(envelope), {
|
|
173
|
+
intro: `Task-bound memory — generation ${envelope.generation}. This is Relay-owned memory bound to Task #${envelope.taskId}; carry these dead ends, decisions, and working state forward.`,
|
|
174
|
+
});
|
|
175
|
+
return truncateTaskMemorySection(rendered, TASK_MEMORY_SECTION_LIMIT);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function boundedTaskMemoryEnvelope(envelope: TaskMemoryEnvelope): ContinuationEnvelope {
|
|
179
|
+
return {
|
|
180
|
+
...envelope,
|
|
181
|
+
workingState: truncateTaskMemoryField(envelope.workingState, TASK_MEMORY_WORKING_STATE_LIMIT, "working state"),
|
|
182
|
+
ledger: boundedLedger(envelope.ledger),
|
|
183
|
+
decisions: boundedDecisions(envelope.decisions),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function boundedLedger(entries: ContinuationLedgerEntry[]): ContinuationLedgerEntry[] {
|
|
188
|
+
const kept = entries.slice(-TASK_MEMORY_LEDGER_LIMIT).map((entry) => ({
|
|
189
|
+
...entry,
|
|
190
|
+
ruledOut: truncateTaskMemoryField(entry.ruledOut, TASK_MEMORY_ENTRY_LIMIT, "ruled-out entry"),
|
|
191
|
+
why: entry.why ? truncateTaskMemoryField(entry.why, TASK_MEMORY_ENTRY_LIMIT, "ruled-out rationale") : "",
|
|
192
|
+
}));
|
|
193
|
+
const omitted = entries.length - kept.length;
|
|
194
|
+
if (omitted <= 0) return kept;
|
|
195
|
+
const firstKept = kept[0] ?? entries[entries.length - 1]!;
|
|
196
|
+
return [{
|
|
197
|
+
gen: firstKept.gen,
|
|
198
|
+
at: firstKept.at,
|
|
199
|
+
ruledOut: `…${omitted} older ruled-out ${omitted === 1 ? "entry" : "entries"} omitted from hydration; use relay_task_memory_read for the full ledger`,
|
|
200
|
+
why: "hydration render cap",
|
|
201
|
+
}, ...kept];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function boundedDecisions(entries: ContinuationDecisionEntry[]): ContinuationDecisionEntry[] {
|
|
205
|
+
const kept = entries.slice(-TASK_MEMORY_DECISION_LIMIT).map((entry) => ({
|
|
206
|
+
...entry,
|
|
207
|
+
decision: truncateTaskMemoryField(entry.decision, TASK_MEMORY_ENTRY_LIMIT, "decision"),
|
|
208
|
+
rationale: entry.rationale ? truncateTaskMemoryField(entry.rationale, TASK_MEMORY_ENTRY_LIMIT, "decision rationale") : "",
|
|
209
|
+
}));
|
|
210
|
+
const omitted = entries.length - kept.length;
|
|
211
|
+
if (omitted <= 0) return kept;
|
|
212
|
+
const firstKept = kept[0] ?? entries[entries.length - 1]!;
|
|
213
|
+
return [{
|
|
214
|
+
gen: firstKept.gen,
|
|
215
|
+
at: firstKept.at,
|
|
216
|
+
decision: `…${omitted} older ${omitted === 1 ? "decision" : "decisions"} omitted from hydration; use relay_task_memory_read for the full decision log`,
|
|
217
|
+
rationale: "hydration render cap",
|
|
218
|
+
by: "relay",
|
|
219
|
+
}, ...kept];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function truncateTaskMemoryField(value: string, max: number, label: string): string {
|
|
223
|
+
if (value.length <= max) return value;
|
|
224
|
+
return `${value.slice(0, max)}\n… (${label} truncated for task-memory hydration; use relay_task_memory_read for the full value)`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function truncateTaskMemorySection(value: string, max: number): string {
|
|
228
|
+
if (value.length <= max) return value;
|
|
229
|
+
return `${value.slice(0, max)}\n… (task memory truncated for hydration; use relay_task_memory_read for the full Task memory)`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Injectable issue-spec reader (defaults to the real issue-ref → tracker path) — for tests. */
|
|
233
|
+
export type IssueDetailFetcher = (link: TaskIssueLink) => Promise<IssueTrackerIssueDetail | null>;
|
|
234
|
+
|
|
235
|
+
async function defaultFetchIssueDetail(link: TaskIssueLink): Promise<IssueTrackerIssueDetail | null> {
|
|
236
|
+
const ref = getIssueRef(link.issueRefId);
|
|
237
|
+
if (!ref) return null;
|
|
238
|
+
const adapter = resolveIssueTrackerAdapter(ref);
|
|
239
|
+
if (!adapter.getDetail) return null;
|
|
240
|
+
return adapter.getDetail(ref);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Race an async issue read against a timeout so a hung async fetcher can't stall hydration. */
|
|
244
|
+
function withFetchTimeout(p: Promise<IssueTrackerIssueDetail | null>, timeoutMs: number): Promise<IssueTrackerIssueDetail | null> {
|
|
245
|
+
return Promise.race([
|
|
246
|
+
p,
|
|
247
|
+
new Promise<IssueTrackerIssueDetail | null>((_, reject) => {
|
|
248
|
+
const t = setTimeout(() => reject(new Error(`issue read exceeded ${timeoutMs}ms`)), timeoutMs);
|
|
249
|
+
(t as { unref?: () => void }).unref?.();
|
|
250
|
+
}),
|
|
251
|
+
]);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export interface HydrateTaskContextInput {
|
|
255
|
+
taskId: number;
|
|
256
|
+
/** Latest one-line steer; overrides the Task's stored `latestSteer` when present. */
|
|
257
|
+
steer?: string;
|
|
258
|
+
/** Override the server-side issue read (tests inject a stub; production uses the tracker). */
|
|
259
|
+
fetchIssueDetail?: IssueDetailFetcher;
|
|
260
|
+
/** Bound for the async issue read race (defaults to ISSUE_FETCH_TIMEOUT_MS; tests shrink it). */
|
|
261
|
+
fetchTimeoutMs?: number;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface TaskHydrationResult {
|
|
265
|
+
task: TaskDetail;
|
|
266
|
+
/** The rendered first-turn brief. */
|
|
267
|
+
context: string;
|
|
268
|
+
/** The effective steer (input override else the Task's stored steer). */
|
|
269
|
+
steer?: string;
|
|
270
|
+
facets: TaskHydrationFacets;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Assemble a spawned worker's first-turn brief FROM the durable Task. Reads the Task detail,
|
|
275
|
+
* its event ledger, and each linked issue's spec (server-side, once), classifies the ledger
|
|
276
|
+
* into decisions vs. history, and renders the brief. A linked-issue read that fails degrades
|
|
277
|
+
* gracefully (the brief notes the spec is unavailable) — it never fails the spawn.
|
|
278
|
+
*/
|
|
279
|
+
export async function hydrateTaskContext(input: HydrateTaskContextInput): Promise<TaskHydrationResult> {
|
|
280
|
+
const task = getTaskDetail(input.taskId);
|
|
281
|
+
if (!task) throw new ValidationError(`task ${input.taskId} not found or not a deliverable task`);
|
|
282
|
+
|
|
283
|
+
const events = listTaskEvents(input.taskId);
|
|
284
|
+
const decisions = events.filter(isDecisionEvent);
|
|
285
|
+
const history = events.slice(-HISTORY_LIMIT);
|
|
286
|
+
|
|
287
|
+
const fetcher = input.fetchIssueDetail ?? defaultFetchIssueDetail;
|
|
288
|
+
const timeoutMs = input.fetchTimeoutMs ?? ISSUE_FETCH_TIMEOUT_MS;
|
|
289
|
+
const issues: ResolvedIssueSpec[] = await Promise.all(
|
|
290
|
+
task.issues.map(async (link): Promise<ResolvedIssueSpec> => {
|
|
291
|
+
try {
|
|
292
|
+
const detail = await withFetchTimeout(Promise.resolve(fetcher(link)), timeoutMs);
|
|
293
|
+
if (!detail) return { link };
|
|
294
|
+
return { link, body: detail.body };
|
|
295
|
+
} catch {
|
|
296
|
+
// Read failed OR timed out (auth/network/rate limit/hang): degrade, never break the spawn.
|
|
297
|
+
return { link, unavailable: true };
|
|
298
|
+
}
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const steer = input.steer?.trim() || task.latestSteer;
|
|
303
|
+
const memory = renderTaskMemory(readTaskMemoryEnvelope(input.taskId));
|
|
304
|
+
const facets: TaskHydrationFacets = {
|
|
305
|
+
taskId: task.id,
|
|
306
|
+
title: task.title,
|
|
307
|
+
status: task.status,
|
|
308
|
+
stage: task.deliverableStage ?? "none",
|
|
309
|
+
...(task.ownerAgentId ? { owner: task.ownerAgentId } : {}),
|
|
310
|
+
intent: task.intent ?? task.title,
|
|
311
|
+
...(task.doneWhen ? { doneWhen: task.doneWhen } : {}),
|
|
312
|
+
issues,
|
|
313
|
+
decisions,
|
|
314
|
+
history,
|
|
315
|
+
...(steer ? { steer } : {}),
|
|
316
|
+
// #815 seam: when scoped-knowledge chaining lands, this task-local read becomes the
|
|
317
|
+
// session/task scope in the global→tenant→host→project→role→team→session/task chain.
|
|
318
|
+
...(memory ? { memory } : {}),
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
return { task, context: renderTaskHydration(facets), steer, facets };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export interface TaskHydrationProjection {
|
|
325
|
+
systemPromptAppend?: string;
|
|
326
|
+
prompt?: string;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function joinBlocks(...parts: Array<string | undefined>): string | undefined {
|
|
330
|
+
const joined = parts.map((p) => p?.trim()).filter((p): p is string => Boolean(p)).join("\n\n");
|
|
331
|
+
return joined || undefined;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* PURE projection: map the hydration brief onto the provider-contract spawn channel.
|
|
336
|
+
* Caller-supplied systemPromptAppend/prompt are preserved (appended after the relay material).
|
|
337
|
+
* The steer used as a first-message kick is sentinel-stripped so it cannot forge the
|
|
338
|
+
* data fence in the (instruction-bearing) systemPromptAppend brief.
|
|
339
|
+
*/
|
|
340
|
+
export function projectTaskHydration(
|
|
341
|
+
provider: SpawnProvider | string,
|
|
342
|
+
context: string,
|
|
343
|
+
steer: string | undefined,
|
|
344
|
+
callerSystemPromptAppend: string | undefined,
|
|
345
|
+
callerPrompt: string | undefined,
|
|
346
|
+
): TaskHydrationProjection {
|
|
347
|
+
if (taskHydrationChannel(provider) === "append-system-prompt") {
|
|
348
|
+
return {
|
|
349
|
+
systemPromptAppend: joinBlocks(context, callerSystemPromptAppend),
|
|
350
|
+
prompt: joinBlocks(steer ? stripSentinels(steer) : undefined, callerPrompt),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
systemPromptAppend: joinBlocks(callerSystemPromptAppend),
|
|
355
|
+
prompt: joinBlocks(context, callerPrompt),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function taskHydrationChannel(provider: SpawnProvider | string): "prompt" | "append-system-prompt" {
|
|
360
|
+
return getManifest(provider)?.launch?.taskHydration?.channel ?? "prompt";
|
|
361
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// #834 — service facade for the durable Task entity. Transports (HTTP routes, MCP tools)
|
|
2
|
+
// call THIS, not the db writers directly (transport-thinness, epic #342): the service owns
|
|
3
|
+
// the db mutation + the SSE emission so the transport stays thin. The kind partition and all
|
|
4
|
+
// validation live in the db layer (src/db/task-entity.ts); this layer adds eventing.
|
|
5
|
+
import {
|
|
6
|
+
addTaskDependency,
|
|
7
|
+
addTaskRef,
|
|
8
|
+
assignTaskAgent,
|
|
9
|
+
createDeliverableTask,
|
|
10
|
+
getTaskDetail,
|
|
11
|
+
listDeliverableTasks,
|
|
12
|
+
listTaskEvents,
|
|
13
|
+
recordGate,
|
|
14
|
+
removeTaskDependency,
|
|
15
|
+
removeTaskRef,
|
|
16
|
+
setTaskStage,
|
|
17
|
+
setTaskStatus,
|
|
18
|
+
updateDeliverableTask,
|
|
19
|
+
type AddTaskRefInput,
|
|
20
|
+
type CreateDeliverableTaskInput,
|
|
21
|
+
type ListDeliverableTasksFilter,
|
|
22
|
+
type RecordGateInput,
|
|
23
|
+
type RemoveTaskRefInput,
|
|
24
|
+
type SetTaskStageInput,
|
|
25
|
+
type SetTaskStatusInput,
|
|
26
|
+
type UpdateDeliverableTaskInput,
|
|
27
|
+
} from "../db";
|
|
28
|
+
import { emitTaskChanged } from "../sse";
|
|
29
|
+
import type { Task, TaskDetail, TaskEvent, TaskGateState } from "../types";
|
|
30
|
+
import { emitTaskSemanticNotification, taskTransitionForStatus } from "./task-semantic-events";
|
|
31
|
+
|
|
32
|
+
export function createTaskEntity(input: CreateDeliverableTaskInput): TaskDetail {
|
|
33
|
+
const detail = createDeliverableTask(input);
|
|
34
|
+
emitTaskChanged(detail, "task.created");
|
|
35
|
+
emitTaskSemanticNotification({
|
|
36
|
+
task: detail,
|
|
37
|
+
transition: "created",
|
|
38
|
+
actor: input.createdBy,
|
|
39
|
+
recipients: input.createdBy ? [input.createdBy] : [],
|
|
40
|
+
occurrenceKey: latestTaskEventOccurrence(detail.id, "task.created"),
|
|
41
|
+
});
|
|
42
|
+
return detail;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getTaskEntity(id: number): TaskDetail | null {
|
|
46
|
+
return getTaskDetail(id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function listTaskEntities(filter: ListDeliverableTasksFilter = {}): Task[] {
|
|
50
|
+
return listDeliverableTasks(filter);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function updateTaskEntity(id: number, input: UpdateDeliverableTaskInput): TaskDetail {
|
|
54
|
+
const detail = updateDeliverableTask(id, input);
|
|
55
|
+
emitTaskChanged(detail, "task.updated");
|
|
56
|
+
return detail;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function setTaskEntityStatus(id: number, input: SetTaskStatusInput): TaskDetail {
|
|
60
|
+
const before = getTaskDetail(id);
|
|
61
|
+
const detail = setTaskStatus(id, input);
|
|
62
|
+
emitTaskChanged(detail, "task.status");
|
|
63
|
+
const transition = taskTransitionForStatus(detail.status, detail.blockedBy);
|
|
64
|
+
if (input.actor !== "task-lifecycle" && transition && shouldEmitStatusTransition(before, detail, transition)) {
|
|
65
|
+
emitTaskSemanticNotification({
|
|
66
|
+
task: detail,
|
|
67
|
+
transition,
|
|
68
|
+
actor: input.actor,
|
|
69
|
+
recipients: detail.createdBy ? [detail.createdBy] : [],
|
|
70
|
+
occurrenceKey: latestTaskEventOccurrence(detail.id, `status:${detail.status}`),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return detail;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function setTaskEntityStage(id: number, input: SetTaskStageInput): TaskDetail {
|
|
77
|
+
const before = getTaskDetail(id);
|
|
78
|
+
const detail = setTaskStage(id, input);
|
|
79
|
+
emitTaskChanged(detail, "task.stage");
|
|
80
|
+
if (input.actor !== "task-lifecycle" && before?.deliverableStage !== "landed-on-main" && detail.deliverableStage === "landed-on-main") {
|
|
81
|
+
emitTaskSemanticNotification({
|
|
82
|
+
task: detail,
|
|
83
|
+
transition: "landed",
|
|
84
|
+
actor: input.actor,
|
|
85
|
+
recipients: detail.createdBy ? [detail.createdBy] : [],
|
|
86
|
+
branch: detail.branch,
|
|
87
|
+
occurrenceKey: latestTaskEventOccurrence(detail.id, `stage:${detail.deliverableStage}`),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return detail;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function latestTaskEventOccurrence(taskId: number, type: string): string | undefined {
|
|
94
|
+
const event = [...taskEntityHistory(taskId)].reverse().find((item) => item.type === type);
|
|
95
|
+
return event ? `event:${event.id}` : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function shouldEmitStatusTransition(before: TaskDetail | null, detail: TaskDetail, transition: ReturnType<typeof taskTransitionForStatus>): boolean {
|
|
99
|
+
if (!transition) return false;
|
|
100
|
+
if (!before || before.status !== detail.status) return true;
|
|
101
|
+
if (transition === "needs-decision") {
|
|
102
|
+
return !before.blockedBy?.some((blocker) => blocker.type === "decision")
|
|
103
|
+
&& detail.blockedBy?.some((blocker) => blocker.type === "decision") === true;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function recordTaskEntityGate(id: number, input: RecordGateInput): { gate: TaskGateState; gates: TaskGateState[] } {
|
|
109
|
+
const gate = recordGate(id, input);
|
|
110
|
+
const detail = getTaskDetail(id);
|
|
111
|
+
if (detail) emitTaskChanged(detail, "task.gate");
|
|
112
|
+
return { gate, gates: detail?.gates ?? [] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function addTaskEntityDependency(taskId: number, dependsOnTaskId: number, actor?: string): TaskDetail {
|
|
116
|
+
addTaskDependency(taskId, dependsOnTaskId, actor);
|
|
117
|
+
const detail = getTaskDetail(taskId)!;
|
|
118
|
+
emitTaskChanged(detail, "task.updated");
|
|
119
|
+
return detail;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function removeTaskEntityDependency(taskId: number, dependsOnTaskId: number, actor?: string): TaskDetail | null {
|
|
123
|
+
removeTaskDependency(taskId, dependsOnTaskId, actor);
|
|
124
|
+
const detail = getTaskDetail(taskId);
|
|
125
|
+
if (detail) emitTaskChanged(detail, "task.updated");
|
|
126
|
+
return detail;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function addTaskEntityRef(id: number, input: AddTaskRefInput): TaskDetail {
|
|
130
|
+
const detail = addTaskRef(id, input);
|
|
131
|
+
emitTaskChanged(detail, "task.updated");
|
|
132
|
+
return detail;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function removeTaskEntityRef(id: number, input: RemoveTaskRefInput): TaskDetail {
|
|
136
|
+
const detail = removeTaskRef(id, input);
|
|
137
|
+
emitTaskChanged(detail, "task.updated");
|
|
138
|
+
return detail;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function assignTaskEntityAgent(id: number, agentId: string, role?: string, actor?: string): TaskDetail {
|
|
142
|
+
const detail = assignTaskAgent(id, agentId, role, actor);
|
|
143
|
+
emitTaskChanged(detail, "task.updated");
|
|
144
|
+
return detail;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function taskEntityHistory(id: number): TaskEvent[] {
|
|
148
|
+
return listTaskEvents(id);
|
|
149
|
+
}
|