@desplega.ai/agent-swarm 1.74.4 → 1.76.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/README.md +1 -1
- package/openapi.json +1264 -46
- package/package.json +2 -2
- package/src/be/db.ts +563 -9
- package/src/be/memory/edges-store.ts +69 -0
- package/src/be/memory/providers/sqlite-store.ts +4 -0
- package/src/be/memory/raters/explicit-self.ts +22 -0
- package/src/be/memory/raters/implicit-citation.ts +44 -0
- package/src/be/memory/raters/llm-client.ts +172 -0
- package/src/be/memory/raters/llm-summarizer.ts +218 -0
- package/src/be/memory/raters/llm.ts +375 -0
- package/src/be/memory/raters/noop.ts +14 -0
- package/src/be/memory/raters/registry.ts +86 -0
- package/src/be/memory/raters/retrieval.ts +88 -0
- package/src/be/memory/raters/run-server-raters.ts +97 -0
- package/src/be/memory/raters/store.ts +228 -0
- package/src/be/memory/raters/types.ts +101 -0
- package/src/be/memory/reranker.ts +32 -2
- package/src/be/memory/retrieval-store.ts +116 -0
- package/src/be/memory/types.ts +3 -0
- package/src/be/migrations/051_memory_posteriors_and_retrieval.sql +67 -0
- package/src/be/migrations/052_memory_edges.sql +36 -0
- package/src/be/migrations/053_agent_waiting_for_credentials_status.sql +61 -0
- package/src/be/migrations/054_agent_harness_provider.sql +21 -0
- package/src/be/migrations/055_agent_cred_status.sql +15 -0
- package/src/be/migrations/056_drop_agent_tasks_source_check.sql +139 -0
- package/src/be/migrations/057_inbox_item_state.sql +27 -0
- package/src/be/migrations/058_task_templates.sql +31 -0
- package/src/be/swarm-config-guard.ts +24 -0
- package/src/commands/credential-wait.ts +186 -0
- package/src/commands/provider-credentials.ts +434 -0
- package/src/commands/runner.ts +253 -21
- package/src/hooks/hook.ts +143 -66
- package/src/http/agents.ts +191 -1
- package/src/http/config.ts +11 -1
- package/src/http/core.ts +5 -0
- package/src/http/inbox-state.ts +89 -0
- package/src/http/index.ts +10 -0
- package/src/http/memory.ts +230 -1
- package/src/http/sessions.ts +86 -0
- package/src/http/status.ts +665 -0
- package/src/http/task-templates.ts +51 -0
- package/src/http/tasks.ts +85 -5
- package/src/http/users.ts +134 -0
- package/src/prompts/memories.ts +62 -0
- package/src/providers/claude-adapter.ts +22 -0
- package/src/providers/claude-managed-adapter.ts +24 -0
- package/src/providers/codex-adapter.ts +43 -1
- package/src/providers/devin-adapter.ts +18 -0
- package/src/providers/index.ts +7 -0
- package/src/providers/opencode-adapter.ts +60 -0
- package/src/providers/pi-mono-adapter.ts +71 -0
- package/src/providers/types.ts +34 -0
- package/src/server.ts +2 -0
- package/src/slack/handlers.ts +0 -1
- package/src/tests/agents-harness-provider.test.ts +333 -0
- package/src/tests/credential-check.test.ts +367 -0
- package/src/tests/credential-status-api.test.ts +223 -0
- package/src/tests/credential-status-routing.test.ts +150 -0
- package/src/tests/credential-wait.test.ts +282 -0
- package/src/tests/harness-provider-resolution.test.ts +242 -0
- package/src/tests/jira-sync.test.ts +1 -1
- package/src/tests/memory-edges.test.ts +722 -0
- package/src/tests/memory-rate-endpoint.test.ts +330 -0
- package/src/tests/memory-rate-tool.test.ts +252 -0
- package/src/tests/memory-rater-e2e.test.ts +578 -0
- package/src/tests/memory-rater-implicit-citation.test.ts +304 -0
- package/src/tests/memory-rater-llm-summarizer.test.ts +317 -0
- package/src/tests/memory-rater-llm.test.ts +964 -0
- package/src/tests/memory-rater-store.test.ts +249 -0
- package/src/tests/memory-reranker.test.ts +161 -2
- package/src/tests/migration-runner-regressions.test.ts +17 -2
- package/src/tests/mocks/mock-llm-rater-client.ts +35 -0
- package/src/tests/run-server-raters.test.ts +291 -0
- package/src/tests/sessions.test.ts +141 -0
- package/src/tests/status.test.ts +843 -0
- package/src/tests/stop-hook-task-resolution.test.ts +98 -0
- package/src/tests/template-recommendations.test.ts +148 -0
- package/src/tests/tool-annotations.test.ts +2 -2
- package/src/tests/use-dismissible-card.test.ts +140 -0
- package/src/tools/memory-rate.ts +166 -0
- package/src/tools/memory-search.ts +18 -0
- package/src/tools/store-progress.ts +37 -0
- package/src/tools/swarm-config/set-config.ts +17 -1
- package/src/tools/tool-config.ts +1 -0
- package/src/types.ts +122 -1
- package/src/utils/harness-provider.ts +32 -0
- package/tsconfig.json +0 -2
package/src/http/memory.ts
CHANGED
|
@@ -3,10 +3,19 @@ import { z } from "zod";
|
|
|
3
3
|
import { chunkContent } from "../be/chunking";
|
|
4
4
|
import { getEmbeddingProvider, getMemoryStore } from "../be/memory";
|
|
5
5
|
import { CANDIDATE_SET_MULTIPLIER } from "../be/memory/constants";
|
|
6
|
+
import { listEdgesForAgent } from "../be/memory/edges-store";
|
|
7
|
+
import { recordRetrievals } from "../be/memory/raters/retrieval";
|
|
8
|
+
import { applyRating, ExplicitSelfDuplicateError } from "../be/memory/raters/store";
|
|
9
|
+
import {
|
|
10
|
+
type RatingEvent,
|
|
11
|
+
REFERENCES_SOURCE_MAX_LENGTH,
|
|
12
|
+
sanitizeReferencesSource,
|
|
13
|
+
} from "../be/memory/raters/types";
|
|
6
14
|
import { rerank } from "../be/memory/reranker";
|
|
15
|
+
import { getRetrievalsForAgent, hasRetrievalForTask } from "../be/memory/retrieval-store";
|
|
7
16
|
import { AgentMemoryScopeSchema, AgentMemorySourceSchema } from "../types";
|
|
8
17
|
import { route } from "./route-def";
|
|
9
|
-
import { json, jsonError } from "./utils";
|
|
18
|
+
import { json, jsonError, parseQueryParams } from "./utils";
|
|
10
19
|
|
|
11
20
|
// ─── Route Definitions ───────────────────────────────────────────────────────
|
|
12
21
|
|
|
@@ -115,6 +124,105 @@ const deleteMemoryById = route({
|
|
|
115
124
|
},
|
|
116
125
|
});
|
|
117
126
|
|
|
127
|
+
// Memory rater v1.5 — worker-facing rating endpoints. Plan:
|
|
128
|
+
// thoughts/taras/plans/2026-05-05-memory-rater-v1.5/step-3.md
|
|
129
|
+
//
|
|
130
|
+
// `source` is restricted to `llm` and `explicit-self` at the HTTP boundary —
|
|
131
|
+
// `implicit-citation` runs in-process server-side via applyRating directly
|
|
132
|
+
// and must never arrive over HTTP (defence against worker spoofing).
|
|
133
|
+
// `referencesSource` (step-6 §4) — Q2 free-form contract: ≤512 chars,
|
|
134
|
+
// control-char strip, NUL byte rejection. Convention `<source>:<identifier>`
|
|
135
|
+
// (e.g. github:owner/repo#N, linear:KEY-N, customer:<slug>) is documented
|
|
136
|
+
// only in the OpenAPI description — server does NOT validate prefixes and
|
|
137
|
+
// does NOT enforce a closed enum. The transform throws via `z.NEVER` when
|
|
138
|
+
// sanitization rejects the input so the request fails with a clear 400.
|
|
139
|
+
const ReferencesSourceSchema = z
|
|
140
|
+
.string()
|
|
141
|
+
.min(1)
|
|
142
|
+
.max(REFERENCES_SOURCE_MAX_LENGTH)
|
|
143
|
+
.transform((value, ctx) => {
|
|
144
|
+
const cleaned = sanitizeReferencesSource(value);
|
|
145
|
+
if (cleaned === null) {
|
|
146
|
+
ctx.addIssue({
|
|
147
|
+
code: z.ZodIssueCode.custom,
|
|
148
|
+
message: "referencesSource must not contain NUL bytes or strip to empty",
|
|
149
|
+
});
|
|
150
|
+
return z.NEVER;
|
|
151
|
+
}
|
|
152
|
+
return cleaned;
|
|
153
|
+
})
|
|
154
|
+
.describe(
|
|
155
|
+
'Optional external source ID this memory references. Free-form string, convention "<source>:<identifier>" (e.g. "github:owner/repo#N", "linear:KEY-N", "customer:<slug>", "slack:<channel>:<ts>", "agentmail:<thread-id>"). Pick any prefix that fits — no closed enum. When present, an edge from this memory to the external source is created/updated.',
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const RateEventSchema = z.object({
|
|
159
|
+
memoryId: z.string().min(1),
|
|
160
|
+
signal: z.number().min(-1).max(1),
|
|
161
|
+
weight: z.number().min(0).max(1),
|
|
162
|
+
source: z.enum(["llm", "explicit-self"]),
|
|
163
|
+
reasoning: z.string().max(500).optional(),
|
|
164
|
+
taskId: z.string().uuid().optional(),
|
|
165
|
+
referencesSource: ReferencesSourceSchema.optional(),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const rateMemory = route({
|
|
169
|
+
method: "post",
|
|
170
|
+
path: "/api/memory/rate",
|
|
171
|
+
pattern: ["api", "memory", "rate"],
|
|
172
|
+
summary: "Submit RatingEvents to update memory usefulness posteriors",
|
|
173
|
+
tags: ["Memory"],
|
|
174
|
+
auth: { apiKey: true, agentId: true },
|
|
175
|
+
body: z.object({
|
|
176
|
+
events: z.array(RateEventSchema).min(1).max(50),
|
|
177
|
+
}),
|
|
178
|
+
responses: {
|
|
179
|
+
200: { description: "Ratings applied; per-event rejections returned in body" },
|
|
180
|
+
400: { description: "Validation error or explicit-self R6 spam-guard rejection" },
|
|
181
|
+
409: { description: "Duplicate explicit-self rating for (taskId, memoryId)" },
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const getRetrievals = route({
|
|
186
|
+
method: "get",
|
|
187
|
+
path: "/api/memory/retrievals",
|
|
188
|
+
pattern: ["api", "memory", "retrievals"],
|
|
189
|
+
summary: "List memories retrieved for a task or session (rater input)",
|
|
190
|
+
tags: ["Memory"],
|
|
191
|
+
auth: { apiKey: true, agentId: true },
|
|
192
|
+
query: z
|
|
193
|
+
.object({
|
|
194
|
+
taskId: z.string().uuid().optional(),
|
|
195
|
+
sessionId: z.string().optional(),
|
|
196
|
+
})
|
|
197
|
+
.refine((q) => q.taskId || q.sessionId, {
|
|
198
|
+
message: "taskId or sessionId required",
|
|
199
|
+
}),
|
|
200
|
+
responses: {
|
|
201
|
+
200: { description: "Retrieval rows joined with agent_memory" },
|
|
202
|
+
400: { description: "Missing taskId/sessionId or X-Agent-ID" },
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Memory rater v1.5 step-6 — the edges-list endpoint that powers the
|
|
207
|
+
// homepage demo ("this memory references PR #377"). Auth by X-Agent-ID +
|
|
208
|
+
// Bearer with defence-in-depth: the joined `agent_memory` row must either
|
|
209
|
+
// be swarm-scope or owned by the requesting agent. Plan §7.
|
|
210
|
+
const getMemoryEdges = route({
|
|
211
|
+
method: "get",
|
|
212
|
+
path: "/api/memory/edges",
|
|
213
|
+
pattern: ["api", "memory", "edges"],
|
|
214
|
+
summary: "List references-source edges for a memory",
|
|
215
|
+
tags: ["Memory"],
|
|
216
|
+
auth: { apiKey: true, agentId: true },
|
|
217
|
+
query: z.object({
|
|
218
|
+
memoryId: z.string().min(1),
|
|
219
|
+
}),
|
|
220
|
+
responses: {
|
|
221
|
+
200: { description: "Edges with computed usefulness scores" },
|
|
222
|
+
400: { description: "Missing memoryId or X-Agent-ID" },
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
118
226
|
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
119
227
|
|
|
120
228
|
export async function handleMemory(
|
|
@@ -211,6 +319,27 @@ export async function handleMemory(
|
|
|
211
319
|
});
|
|
212
320
|
const ranked = rerank(candidates, { limit: Math.min(limit, 20) });
|
|
213
321
|
|
|
322
|
+
// Retrieval bridge — when caller passed `X-Source-Task-ID`, record one
|
|
323
|
+
// `memory_retrieval` row per returned memory so server-side raters
|
|
324
|
+
// (ImplicitCitationRater, fired from store-progress on task completion)
|
|
325
|
+
// know which memories were surfaced. Best-effort: a logging failure must
|
|
326
|
+
// never poison search.
|
|
327
|
+
const sourceTaskIdHeader = req.headers["x-source-task-id"];
|
|
328
|
+
const sourceTaskId = Array.isArray(sourceTaskIdHeader)
|
|
329
|
+
? sourceTaskIdHeader[0]
|
|
330
|
+
: sourceTaskIdHeader;
|
|
331
|
+
if (sourceTaskId) {
|
|
332
|
+
try {
|
|
333
|
+
recordRetrievals(
|
|
334
|
+
sourceTaskId,
|
|
335
|
+
myAgentId,
|
|
336
|
+
ranked.map((r) => ({ memoryId: r.id, similarity: r.similarity })),
|
|
337
|
+
);
|
|
338
|
+
} catch (err) {
|
|
339
|
+
console.error("[memory-search] recordRetrievals failed:", (err as Error).message);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
214
343
|
json(res, {
|
|
215
344
|
results: ranked.map((r) => ({
|
|
216
345
|
id: r.id,
|
|
@@ -389,5 +518,105 @@ export async function handleMemory(
|
|
|
389
518
|
return true;
|
|
390
519
|
}
|
|
391
520
|
|
|
521
|
+
if (rateMemory.match(req.method, pathSegments)) {
|
|
522
|
+
if (!myAgentId) {
|
|
523
|
+
jsonError(res, "Missing X-Agent-ID header", 400);
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const parsed = await rateMemory.parse(req, res, pathSegments, new URLSearchParams());
|
|
528
|
+
if (!parsed) return true;
|
|
529
|
+
|
|
530
|
+
const { events } = parsed.body;
|
|
531
|
+
|
|
532
|
+
// R6 spam guard: explicit-self requires a matching memory_retrieval row.
|
|
533
|
+
// Reject the whole batch on first offender so the worker sees a clear 400.
|
|
534
|
+
for (const evt of events) {
|
|
535
|
+
if (evt.source !== "explicit-self") continue;
|
|
536
|
+
if (!evt.taskId) {
|
|
537
|
+
jsonError(res, `explicit-self rating for memoryId=${evt.memoryId} requires taskId`, 400);
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
if (!hasRetrievalForTask(evt.taskId, evt.memoryId)) {
|
|
541
|
+
jsonError(
|
|
542
|
+
res,
|
|
543
|
+
`explicit-self rating rejected: memoryId=${evt.memoryId} not present in memory_retrieval for task=${evt.taskId}`,
|
|
544
|
+
400,
|
|
545
|
+
);
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// applyRating's ctx carries a single taskId for the batch. Group events by
|
|
551
|
+
// taskId so each call gets a single coherent ctx (and one transaction).
|
|
552
|
+
const groups = new Map<string | undefined, typeof events>();
|
|
553
|
+
for (const evt of events) {
|
|
554
|
+
const list = groups.get(evt.taskId) ?? [];
|
|
555
|
+
list.push(evt);
|
|
556
|
+
groups.set(evt.taskId, list);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
let applied = 0;
|
|
560
|
+
const rejected: { memoryId: string; reason: string }[] = [];
|
|
561
|
+
try {
|
|
562
|
+
for (const [taskId, batch] of groups) {
|
|
563
|
+
const ratingEvents: RatingEvent[] = batch.map((e) => ({
|
|
564
|
+
memoryId: e.memoryId,
|
|
565
|
+
signal: e.signal,
|
|
566
|
+
weight: e.weight,
|
|
567
|
+
source: e.source,
|
|
568
|
+
reasoning: e.reasoning,
|
|
569
|
+
...(e.referencesSource !== undefined ? { referencesSource: e.referencesSource } : {}),
|
|
570
|
+
}));
|
|
571
|
+
const result = applyRating(ratingEvents, { taskId });
|
|
572
|
+
applied += result.applied;
|
|
573
|
+
for (const r of result.rejected) {
|
|
574
|
+
rejected.push({ memoryId: r.event.memoryId, reason: r.reason });
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
} catch (err) {
|
|
578
|
+
if (err instanceof ExplicitSelfDuplicateError) {
|
|
579
|
+
jsonError(res, `Duplicate explicit-self rating for memoryId=${err.event.memoryId}`, 409);
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
throw err;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
json(res, { applied, rejected });
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (getRetrievals.match(req.method, pathSegments)) {
|
|
590
|
+
if (!myAgentId) {
|
|
591
|
+
jsonError(res, "Missing X-Agent-ID header", 400);
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
596
|
+
const parsed = await getRetrievals.parse(req, res, pathSegments, queryParams);
|
|
597
|
+
if (!parsed) return true;
|
|
598
|
+
|
|
599
|
+
const { taskId, sessionId } = parsed.query;
|
|
600
|
+
const rows = getRetrievalsForAgent(myAgentId, { taskId, sessionId });
|
|
601
|
+
json(res, { results: rows });
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (getMemoryEdges.match(req.method, pathSegments)) {
|
|
606
|
+
if (!myAgentId) {
|
|
607
|
+
jsonError(res, "Missing X-Agent-ID header", 400);
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
612
|
+
const parsed = await getMemoryEdges.parse(req, res, pathSegments, queryParams);
|
|
613
|
+
if (!parsed) return true;
|
|
614
|
+
|
|
615
|
+
const { memoryId } = parsed.query;
|
|
616
|
+
const edges = listEdgesForAgent(myAgentId, memoryId);
|
|
617
|
+
json(res, { edges });
|
|
618
|
+
return true;
|
|
619
|
+
}
|
|
620
|
+
|
|
392
621
|
return false;
|
|
393
622
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getRootTaskChain, getTaskById, listRecentSessions } from "../be/db";
|
|
4
|
+
import { route } from "./route-def";
|
|
5
|
+
import { json, jsonError } from "./utils";
|
|
6
|
+
|
|
7
|
+
// ─── Route Definitions ───────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
const listSessions = route({
|
|
10
|
+
method: "get",
|
|
11
|
+
path: "/api/sessions",
|
|
12
|
+
pattern: ["api", "sessions"],
|
|
13
|
+
summary: "List recent task sessions (root tasks + chain summary)",
|
|
14
|
+
tags: ["Sessions"],
|
|
15
|
+
query: z.object({
|
|
16
|
+
limit: z.coerce.number().int().optional(),
|
|
17
|
+
offset: z.coerce.number().int().optional(),
|
|
18
|
+
/** Comma-separated source filter (e.g. `ui,slack`). Omit to include all. */
|
|
19
|
+
source: z.string().optional(),
|
|
20
|
+
/** Case-insensitive substring match against the root task's text. */
|
|
21
|
+
q: z.string().optional(),
|
|
22
|
+
}),
|
|
23
|
+
responses: {
|
|
24
|
+
200: { description: "Recent sessions ordered by chain-wide last activity" },
|
|
25
|
+
401: { description: "Unauthorized" },
|
|
26
|
+
},
|
|
27
|
+
auth: { apiKey: true },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const getSession = route({
|
|
31
|
+
method: "get",
|
|
32
|
+
path: "/api/sessions/{rootTaskId}",
|
|
33
|
+
pattern: ["api", "sessions", null],
|
|
34
|
+
summary: "Get a session — root task + the entire descendant chain",
|
|
35
|
+
tags: ["Sessions"],
|
|
36
|
+
params: z.object({ rootTaskId: z.string() }),
|
|
37
|
+
responses: {
|
|
38
|
+
200: { description: "Root task + chain (ordered by createdAt)" },
|
|
39
|
+
401: { description: "Unauthorized" },
|
|
40
|
+
404: { description: "Root task not found" },
|
|
41
|
+
},
|
|
42
|
+
auth: { apiKey: true },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export async function handleSessions(
|
|
48
|
+
req: IncomingMessage,
|
|
49
|
+
res: ServerResponse,
|
|
50
|
+
pathSegments: string[],
|
|
51
|
+
queryParams: URLSearchParams,
|
|
52
|
+
): Promise<boolean> {
|
|
53
|
+
if (listSessions.match(req.method, pathSegments)) {
|
|
54
|
+
const parsed = await listSessions.parse(req, res, pathSegments, queryParams);
|
|
55
|
+
if (!parsed) return true;
|
|
56
|
+
const sources = parsed.query.source
|
|
57
|
+
? parsed.query.source
|
|
58
|
+
.split(",")
|
|
59
|
+
.map((s) => s.trim())
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
: undefined;
|
|
62
|
+
const sessions = listRecentSessions({
|
|
63
|
+
limit: parsed.query.limit,
|
|
64
|
+
offset: parsed.query.offset,
|
|
65
|
+
source: sources,
|
|
66
|
+
q: parsed.query.q,
|
|
67
|
+
});
|
|
68
|
+
json(res, { sessions });
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (getSession.match(req.method, pathSegments)) {
|
|
73
|
+
const parsed = await getSession.parse(req, res, pathSegments, queryParams);
|
|
74
|
+
if (!parsed) return true;
|
|
75
|
+
const root = getTaskById(parsed.params.rootTaskId);
|
|
76
|
+
if (!root) {
|
|
77
|
+
jsonError(res, "Root task not found", 404);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
const chain = getRootTaskChain(parsed.params.rootTaskId);
|
|
81
|
+
json(res, { root, chain });
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return false;
|
|
86
|
+
}
|