@desplega.ai/agent-swarm 2.5.2 → 2.5.3
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/dist/{acp-adapter-csh0zz3b.js → acp-adapter-gr2fcyyj.js} +3 -3
- package/dist/{actions-6xx079d1.js → actions-wv766ghf.js} +18 -18
- package/dist/{amp-adapter-8n6f0hz8.js → amp-adapter-speb906t.js} +3 -3
- package/dist/{app-ft6r6v23.js → app-ag29n14f.js} +3 -3
- package/dist/{assistant-vt2cyfzd.js → assistant-3x8bz3eg.js} +19 -19
- package/dist/{boot-reembed-r3e808ky.js → boot-reembed-5b3d2eea.js} +1 -1
- package/dist/{boot-reembed-tgrfy27t.js → boot-reembed-jsmfrpzf.js} +1 -1
- package/dist/{boot-scrub-sweep-masdwxkm.js → boot-scrub-sweep-s73gerxy.js} +2 -2
- package/dist/{boot-seeds-0n82qc7q.js → boot-seeds-5zmax2wr.js} +4 -4
- package/dist/{claude-adapter-s7j47k06.js → claude-adapter-hswdnm3t.js} +3 -3
- package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-cvbzj1h6.js} +2 -2
- package/dist/{claude-managed-setup-3hzrgrnk.js → claude-managed-setup-3k8m3hhc.js} +2 -2
- package/dist/{claude-sdk-session-0zrdzyx2.js → claude-sdk-session-fkbyk66x.js} +2 -2
- package/dist/{cli-d172svcw.js → cli-1fc85mvt.js} +4 -4
- package/dist/{cli-3e9vs93g.js → cli-210xd1f4.js} +4 -4
- package/dist/{cli-vaxw3bg0.js → cli-25j01dpj.js} +3 -3
- package/dist/{cli-n6kmqngw.js → cli-35gtk1p6.js} +1 -1
- package/dist/{cli-fgn7yg5r.js → cli-3ngdk5te.js} +1 -1
- package/dist/{cli-7ccf7akh.js → cli-40jsnrjm.js} +2 -2
- package/dist/{cli-b1tv22wr.js → cli-502eqv93.js} +1 -1
- package/dist/{cli-ydg6ep51.js → cli-6n8axjv1.js} +1 -1
- package/dist/{cli-f55b6ky0.js → cli-8s5wx3nz.js} +2 -2
- package/dist/{cli-86zpfje5.js → cli-99xgw26r.js} +1 -1
- package/dist/{cli-xw9q173h.js → cli-9s05422w.js} +1 -1
- package/dist/{cli-h5d22pfs.js → cli-aecdcr77.js} +119 -282
- package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
- package/dist/{cli-15xd0j7w.js → cli-ce0ak9ms.js} +11 -11
- package/dist/{cli-y3dzwyy3.js → cli-e2ar4bp1.js} +71 -20
- package/dist/{cli-b6tnjdvx.js → cli-e8jyq7d8.js} +1 -1
- package/dist/{cli-6y43v79h.js → cli-egjggehm.js} +1 -1
- package/dist/{cli-cf63dnxj.js → cli-ewm3pwbq.js} +2 -2
- package/dist/{cli-bpbcpd8b.js → cli-fsdprwrg.js} +1 -1
- package/dist/{cli-qrjv7t18.js → cli-fzcn3d87.js} +1 -1
- package/dist/{cli-14v1sxxy.js → cli-gpsz7g88.js} +6 -6
- package/dist/{cli-wadzapwb.js → cli-hcpj2xqz.js} +1 -1
- package/dist/{cli-jmcjbjkt.js → cli-hhxjnjzs.js} +1 -1
- package/dist/{cli-j3dwd4kz.js → cli-jjznk3f7.js} +718 -108
- package/dist/{cli-csh8fnbg.js → cli-kxawjrxg.js} +147 -28
- package/dist/{cli-mntxtkhn.js → cli-mz9afxxf.js} +2 -1
- package/dist/{cli-0vgy5wfw.js → cli-n45mr7zh.js} +2 -2
- package/dist/{cli-aaqnqpyw.js → cli-p0rzjkya.js} +2 -2
- package/dist/{cli-3qv8naq8.js → cli-p6zs9h4y.js} +827 -107
- package/dist/{cli-e91vshb1.js → cli-p9rafdrb.js} +4 -4
- package/dist/{cli-edtm741g.js → cli-qgf8emrn.js} +5 -5
- package/dist/{cli-ha22h0eg.js → cli-qzme5949.js} +26 -26
- package/dist/{cli-xzge46sj.js → cli-s49hpn37.js} +2 -2
- package/dist/{cli-b63d08rs.js → cli-s53vp709.js} +1 -1
- package/dist/{cli-cyfgxnvb.js → cli-svrj71pg.js} +18 -16
- package/dist/{cli-4qkg9968.js → cli-vwn81jbs.js} +3 -3
- package/dist/{cli-f3pfn4x3.js → cli-w1zwctj2.js} +1 -1
- package/dist/{cli-kcnwn85g.js → cli-y561h5a9.js} +4 -4
- package/dist/{cli-n2918sed.js → cli-z21whrrg.js} +15 -15
- package/dist/cli.js +16 -16
- package/dist/{codex-adapter-brh2tcxr.js → codex-adapter-n8ka66vf.js} +2 -2
- package/dist/{codex-session-runner-0dnj21qs.js → codex-session-runner-fsebts37.js} +1 -1
- package/dist/{commands-1cnvseqx.js → commands-t04rt2pc.js} +2 -2
- package/dist/{controller-kc7s9xhn.js → controller-rgna04r2.js} +2 -2
- package/dist/{controllers-connect-jzx7t4bn.js → controllers-connect-bqm1rtft.js} +20 -20
- package/dist/{cursor-adapter-qpwp5sfg.js → cursor-adapter-swr2k95y.js} +1 -1
- package/dist/{db-v5ytg5pw.js → db-24ck09xy.js} +1 -1
- package/dist/{db-4vq88jkc.js → db-a4m7haw0.js} +3 -3
- package/dist/{dispatcher-trtcrqae.js → dispatcher-nwf9ss0q.js} +17 -17
- package/dist/{dsh-adapter-f20xwn1z.js → dsh-adapter-kpdnhjst.js} +2 -2
- package/dist/{external-channel-n9bymc2n.js → external-channel-j1e2p34p.js} +2 -2
- package/dist/{grok-adapter-4kpmmnr6.js → grok-adapter-qtjz0whk.js} +4 -4
- package/dist/{handlers-ptsraqa8.js → handlers-19xr6dnz.js} +20 -20
- package/dist/{heartbeat-60qtxtjf.js → heartbeat-vtesda5d.js} +20 -20
- package/dist/{hook-rbktcstg.js → hook-8wqaszda.js} +1 -1
- package/dist/{hook-ngtvh729.js → hook-b2hhj6rd.js} +1 -1
- package/dist/{http-z8xy72gp.js → http-mxcqhhcs.js} +2757 -2448
- package/dist/{index-a9nsavhx.js → index-1r8wfpzs.js} +1 -1
- package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
- package/dist/{index-s193j5fh.js → index-kw2mmat7.js} +20 -20
- package/dist/{index-06qkmcx6.js → index-n1vbvta9.js} +17 -9
- package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
- package/dist/{index-1cscynkb.js → index-spwxjgay.js} +17 -17
- package/dist/{index-r70q0xjx.js → index-srg522m6.js} +17 -17
- package/dist/{keepalive-a9srcphj.js → keepalive-dsd8rtgx.js} +3 -3
- package/dist/{lead-3devneqy.js → lead-zyerk1x2.js} +30 -30
- package/dist/{maintenance-0er16h2y.js → maintenance-jqmqwnet.js} +1 -1
- package/dist/{oauth-refresh-sweep-b65jcyw2.js → oauth-refresh-sweep-q2c3bnvm.js} +2 -2
- package/dist/{onboard-wqywz5py.js → onboard-w4yd7s8t.js} +2 -2
- package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-9tppvxar.js} +2 -2
- package/dist/{otel-impl-vk32s884.js → otel-impl-f36byzbd.js} +1 -1
- package/dist/{pricing-refresh-t27anv4z.js → pricing-refresh-xvak5p4v.js} +3 -3
- package/dist/{rbac-roles-bmcgsxjn.js → rbac-roles-682k2mt5.js} +1 -1
- package/dist/{rbac-roles-452665y0.js → rbac-roles-gfn30fb8.js} +2 -2
- package/dist/{render-v2-0ttsk2j4.js → render-v2-hgytz8nn.js} +3 -3
- package/dist/{resume-a26kvr41.js → resume-2dvq348g.js} +7 -5
- package/dist/{session-access-dgh9nkwk.js → session-access-7f21ekq9.js} +4 -4
- package/dist/{setup-9vc9qahm.js → setup-epffxtbk.js} +1 -1
- package/dist/{work-objects-vn8nkxwn.js → work-objects-xz1a5rqk.js} +20 -20
- package/dist/{worker-1x1h8vnm.js → worker-42t8emjt.js} +30 -30
- package/openapi.json +3045 -2456
- package/package.json +4 -4
- package/src/be/approval-config-question.ts +389 -0
- package/src/be/automation-preflight.ts +37 -3
- package/src/be/db/session-tree.ts +73 -15
- package/src/be/db/skill-search.ts +87 -0
- package/src/be/db/tasks/read.ts +29 -2
- package/src/be/db/workflow-runs.ts +54 -7
- package/src/be/db.ts +25 -5
- package/src/be/migrations/239_skills_fts.sql +40 -0
- package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
- package/src/be/migrations/241_skills_meta_fts.sql +39 -0
- package/src/be/swarm-config-guard.ts +23 -0
- package/src/extensions/catalog.generated.json +4 -4
- package/src/http/approval-requests.ts +209 -38
- package/src/http/config.ts +22 -37
- package/src/http/sessions.ts +27 -2
- package/src/integrations/chat-sdk/inbound.ts +5 -4
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/tests/approval-config-question.test.ts +702 -0
- package/src/tests/chat-sdk-inbound.test.ts +20 -0
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
- package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
- package/src/tests/list-endpoint-slimming.test.ts +33 -0
- package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
- package/src/tests/script-connections.test.ts +6 -0
- package/src/tests/sessions-tree.test.ts +168 -1
- package/src/tests/skill-search-long-content.test.ts +193 -0
- package/src/tests/skill-search.test.ts +173 -22
- package/src/tests/workflow-sub-workflow.test.ts +538 -1
- package/src/tools/request-human-input.ts +27 -5
- package/src/types.ts +7 -0
- package/src/workflows/approval-notifications.ts +10 -3
- package/src/workflows/checkpoint.ts +69 -35
- package/src/workflows/engine.ts +234 -87
- package/src/workflows/executors/base.ts +6 -0
- package/src/workflows/executors/human-in-the-loop.ts +25 -1
- package/src/workflows/executors/sub-workflow.ts +45 -16
- package/src/workflows/recovery.ts +63 -1
- package/src/workflows/resume.ts +60 -21
- package/src/workflows/retry-poller.ts +25 -4
- package/templates/extensions/comms-adherence-gate/README.md +38 -13
- package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
- package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
- package/templates/skills/turso-interaction/content.md +1 -1
- package/templates/skills/workflow-iterate/content.md +8 -1
- /package/dist/{cli-stxdrqs2.js → cli-avdvgcay.js} +0 -0
- /package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-tqsge1ay.js} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@desplega.ai/agent-swarm",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.3",
|
|
4
4
|
"description": "agent-swarm.dev — an open-source operating system for AI work: a lead agent breaks goals into tasks, routes them to specialized workers such as Claude Code or Codex, runs each worker in an isolated container, and preserves shared memory, tools, schedules, and review gates so delegated work compounds across sessions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent-swarm.dev",
|
|
@@ -180,8 +180,8 @@
|
|
|
180
180
|
"@desplega/model-catalog": "workspace:*",
|
|
181
181
|
"@desplega/model-routing": "workspace:*",
|
|
182
182
|
"@faker-js/faker": "^10.4.0",
|
|
183
|
-
"@opencode/plugin": "2.1.
|
|
184
|
-
"@opencode/sdk": "2.1.
|
|
183
|
+
"@opencode/plugin": "2.1.4",
|
|
184
|
+
"@opencode/sdk": "2.1.4",
|
|
185
185
|
"@oxlint/plugins": "1.78.0",
|
|
186
186
|
"@types/bun": "latest",
|
|
187
187
|
"dependency-cruiser": "^18.0.0",
|
|
@@ -202,7 +202,7 @@
|
|
|
202
202
|
"@agentclientprotocol/sdk": "2.0.1",
|
|
203
203
|
"@ai-sdk/openai": "^3.0.41",
|
|
204
204
|
"@anthropic-ai/claude-agent-sdk": "0.3.309",
|
|
205
|
-
"@anthropic-ai/sdk": "^1.
|
|
205
|
+
"@anthropic-ai/sdk": "^1.2.0",
|
|
206
206
|
"@asteasolutions/zod-to-openapi": "^8.0.0",
|
|
207
207
|
"@aws-sdk/client-bedrock": "3.1048.0",
|
|
208
208
|
"@chat-adapter/discord": "4.41.1",
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HITL `config` questions: ask a person to set one swarm config key (a secret
|
|
3
|
+
* or a plain value) from the dashboard. The answer is written to
|
|
4
|
+
* `swarm_config` by the respond handler, and only a {@link ConfigResult} is
|
|
5
|
+
* ever stored on the request or sent onward, so a secret never reaches the
|
|
6
|
+
* agent, the workflow run, the follow-up task or any notification.
|
|
7
|
+
*/
|
|
8
|
+
import * as z from "zod";
|
|
9
|
+
import { getDbClient } from "./db";
|
|
10
|
+
import { upsertSwarmConfigWithPolicyMirror } from "./multi-runtime";
|
|
11
|
+
import {
|
|
12
|
+
internalConfigKeyError,
|
|
13
|
+
isApiOnlyConfigKey,
|
|
14
|
+
isInternalConfigKey,
|
|
15
|
+
isReservedConfigKey,
|
|
16
|
+
reservedKeyError,
|
|
17
|
+
validateConfigEntry,
|
|
18
|
+
} from "./swarm-config-guard";
|
|
19
|
+
|
|
20
|
+
export const ConfigQuestionSettingsSchema = z.object({
|
|
21
|
+
key: z
|
|
22
|
+
.string()
|
|
23
|
+
.min(1)
|
|
24
|
+
.max(255)
|
|
25
|
+
.regex(/^\S+$/, "must not contain whitespace")
|
|
26
|
+
.describe("The swarm config key to set, e.g. BITBUCKET_TOKEN"),
|
|
27
|
+
scope: z.enum(["global", "agent", "repo"]).describe("Config scope the value is written to"),
|
|
28
|
+
scopeId: z
|
|
29
|
+
.string()
|
|
30
|
+
.min(1)
|
|
31
|
+
.optional()
|
|
32
|
+
.describe(
|
|
33
|
+
"Agent or repo id for agent/repo scope. From request-human-input, agent scope defaults to the calling agent; pass it explicitly when asking on behalf of another agent.",
|
|
34
|
+
),
|
|
35
|
+
isSecret: z
|
|
36
|
+
.boolean()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe(
|
|
39
|
+
"Store encrypted and never show the value (default true). An existing secret row always stays secret.",
|
|
40
|
+
),
|
|
41
|
+
helpUrl: z
|
|
42
|
+
.string()
|
|
43
|
+
.url()
|
|
44
|
+
.refine((url) => /^https?:\/\//i.test(url), "must be an http(s) URL")
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("Link shown next to the field, e.g. where to create the token"),
|
|
47
|
+
options: z
|
|
48
|
+
.array(
|
|
49
|
+
z.object({
|
|
50
|
+
value: z.string(),
|
|
51
|
+
label: z.string(),
|
|
52
|
+
description: z.string().optional(),
|
|
53
|
+
}),
|
|
54
|
+
)
|
|
55
|
+
.optional()
|
|
56
|
+
.describe("Allowed values for a non-secret key, rendered as a choice"),
|
|
57
|
+
defaultValue: z.string().optional().describe("Prefilled value for a non-secret key"),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export type ConfigQuestionSettings = z.infer<typeof ConfigQuestionSettingsSchema>;
|
|
61
|
+
|
|
62
|
+
/** Settings after create-time normalization: `isSecret` is always resolved. */
|
|
63
|
+
export type NormalizedConfigSettings = ConfigQuestionSettings & { isSecret: boolean };
|
|
64
|
+
|
|
65
|
+
/** What the dashboard sends for a config question (D6). */
|
|
66
|
+
export const ConfigAnswerSchema = z.discriminatedUnion("action", [
|
|
67
|
+
z.object({ action: z.literal("set"), value: z.string() }),
|
|
68
|
+
z.object({ action: z.literal("keep") }),
|
|
69
|
+
z.object({ action: z.literal("skip") }),
|
|
70
|
+
]);
|
|
71
|
+
export type ConfigAnswer = z.infer<typeof ConfigAnswerSchema>;
|
|
72
|
+
|
|
73
|
+
/** The only form of a config answer that is ever stored or sent (D7). */
|
|
74
|
+
export const ConfigResultSchema = z.object({
|
|
75
|
+
key: z.string(),
|
|
76
|
+
scope: z.enum(["global", "agent", "repo"]),
|
|
77
|
+
scopeId: z.string().nullable(),
|
|
78
|
+
isSecret: z.boolean(),
|
|
79
|
+
status: z.enum(["set", "kept", "skipped"]),
|
|
80
|
+
updatedAt: z.string().nullable(),
|
|
81
|
+
value: z.string().optional(),
|
|
82
|
+
});
|
|
83
|
+
export type ConfigResult = z.infer<typeof ConfigResultSchema>;
|
|
84
|
+
|
|
85
|
+
/** Live state of the key a config question targets (never persisted). */
|
|
86
|
+
export const ConfigStateSchema = z.object({
|
|
87
|
+
exists: z.boolean(),
|
|
88
|
+
isSecret: z.boolean(),
|
|
89
|
+
updatedAt: z.string().nullable(),
|
|
90
|
+
value: z.string().optional().describe("Current value; present only for non-secret rows"),
|
|
91
|
+
});
|
|
92
|
+
export type ConfigState = z.infer<typeof ConfigStateSchema>;
|
|
93
|
+
|
|
94
|
+
type ConfigQuestionInput = {
|
|
95
|
+
id: string;
|
|
96
|
+
type: string;
|
|
97
|
+
label?: string;
|
|
98
|
+
required?: boolean;
|
|
99
|
+
config?: ConfigQuestionSettings;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type NormalizedConfigQuestion<Q extends ConfigQuestionInput> = Omit<Q, "config"> & {
|
|
103
|
+
label: string;
|
|
104
|
+
required: boolean;
|
|
105
|
+
config: NormalizedConfigSettings;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type ApproversLike = { policy: unknown };
|
|
109
|
+
|
|
110
|
+
interface ConfigRowMeta {
|
|
111
|
+
isSecret: boolean;
|
|
112
|
+
updatedAt: string;
|
|
113
|
+
envPath: string | null;
|
|
114
|
+
description: string | null;
|
|
115
|
+
value?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The exact `(scope, scopeId, key)` row, without decrypting anything. */
|
|
119
|
+
async function readConfigRow(
|
|
120
|
+
settings: Pick<ConfigQuestionSettings, "scope" | "scopeId" | "key">,
|
|
121
|
+
): Promise<ConfigRowMeta | null> {
|
|
122
|
+
const scopeId = settings.scope === "global" ? null : (settings.scopeId ?? null);
|
|
123
|
+
const row = await getDbClient().get<{
|
|
124
|
+
isSecret: number;
|
|
125
|
+
lastUpdatedAt: string;
|
|
126
|
+
envPath: string | null;
|
|
127
|
+
description: string | null;
|
|
128
|
+
plainValue: string | null;
|
|
129
|
+
}>(
|
|
130
|
+
`SELECT isSecret, lastUpdatedAt, envPath, description,
|
|
131
|
+
CASE WHEN isSecret = 1 THEN NULL ELSE value END AS plainValue
|
|
132
|
+
FROM swarm_config
|
|
133
|
+
WHERE scope = ? AND ${scopeId === null ? "scopeId IS NULL" : "scopeId = ?"} AND key = ?`,
|
|
134
|
+
scopeId === null ? [settings.scope, settings.key] : [settings.scope, scopeId, settings.key],
|
|
135
|
+
);
|
|
136
|
+
if (!row) return null;
|
|
137
|
+
// API-only keys are never readable over HTTP, so they count as secret here.
|
|
138
|
+
const isSecret = row.isSecret === 1 || isApiOnlyConfigKey(settings.key);
|
|
139
|
+
return {
|
|
140
|
+
isSecret,
|
|
141
|
+
updatedAt: row.lastUpdatedAt,
|
|
142
|
+
envPath: row.envPath,
|
|
143
|
+
description: row.description,
|
|
144
|
+
...(isSecret || row.plainValue === null ? {} : { value: row.plainValue }),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function readConfigState(settings: ConfigQuestionSettings): Promise<ConfigState> {
|
|
149
|
+
const row = await readConfigRow(settings);
|
|
150
|
+
if (!row) return { exists: false, isSecret: settings.isSecret !== false, updatedAt: null };
|
|
151
|
+
const isSecret = row.isSecret || settings.isSecret !== false;
|
|
152
|
+
return {
|
|
153
|
+
exists: true,
|
|
154
|
+
isSecret,
|
|
155
|
+
updatedAt: row.updatedAt,
|
|
156
|
+
...(!isSecret && row.value !== undefined ? { value: row.value } : {}),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create-time rules for one config question (D2, D3, D5). Defaults `label` to
|
|
162
|
+
* the key and `required` to true, resolves the effective `isSecret` against
|
|
163
|
+
* the live row, and rejects keys and shapes the config API would refuse.
|
|
164
|
+
*/
|
|
165
|
+
export async function normalizeConfigQuestion<Q extends ConfigQuestionInput>(
|
|
166
|
+
question: Q,
|
|
167
|
+
ctx: { approvers: ApproversLike; callerAgentId?: string },
|
|
168
|
+
): Promise<{ ok: true; question: NormalizedConfigQuestion<Q> } | { ok: false; error: string }> {
|
|
169
|
+
const fail = (reason: string) => ({
|
|
170
|
+
ok: false as const,
|
|
171
|
+
error: `config question "${question.id}": ${reason}`,
|
|
172
|
+
});
|
|
173
|
+
const parsed = ConfigQuestionSettingsSchema.safeParse(question.config);
|
|
174
|
+
if (!parsed.success) {
|
|
175
|
+
const issue = parsed.error.issues[0];
|
|
176
|
+
const path = issue?.path.length ? `config.${issue.path.map(String).join(".")}` : "config";
|
|
177
|
+
return fail(`${path}: ${issue?.message ?? "invalid"}`);
|
|
178
|
+
}
|
|
179
|
+
if (ctx.approvers.policy !== "any") {
|
|
180
|
+
return fail('a config question needs approvers.policy "any" (a config value has one author)');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const settings = { ...parsed.data };
|
|
184
|
+
if (settings.scope === "agent" && !settings.scopeId && ctx.callerAgentId) {
|
|
185
|
+
settings.scopeId = ctx.callerAgentId;
|
|
186
|
+
}
|
|
187
|
+
if (settings.scope === "global" && settings.scopeId) {
|
|
188
|
+
return fail("global scope must not have scopeId");
|
|
189
|
+
}
|
|
190
|
+
if (settings.scope !== "global" && !settings.scopeId) {
|
|
191
|
+
return fail(`${settings.scope} scope requires scopeId`);
|
|
192
|
+
}
|
|
193
|
+
if (isReservedConfigKey(settings.key)) return fail(reservedKeyError(settings.key).message);
|
|
194
|
+
if (isInternalConfigKey(settings.key)) return fail(internalConfigKeyError(settings.key).message);
|
|
195
|
+
|
|
196
|
+
const row = await readConfigRow(settings);
|
|
197
|
+
const isSecret =
|
|
198
|
+
settings.isSecret !== false || row?.isSecret === true || isApiOnlyConfigKey(settings.key);
|
|
199
|
+
if (isSecret && (settings.options || settings.defaultValue !== undefined)) {
|
|
200
|
+
return fail(
|
|
201
|
+
"options and defaultValue are only allowed when isSecret is false (and the key is not already stored as a secret)",
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (settings.options?.length === 0) return fail("options must not be empty");
|
|
205
|
+
if (
|
|
206
|
+
settings.options &&
|
|
207
|
+
settings.defaultValue !== undefined &&
|
|
208
|
+
!settings.options.some((option) => option.value === settings.defaultValue)
|
|
209
|
+
) {
|
|
210
|
+
return fail("defaultValue must be one of options");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const label = question.label?.trim() ? question.label : settings.key;
|
|
214
|
+
return {
|
|
215
|
+
ok: true,
|
|
216
|
+
question: {
|
|
217
|
+
...question,
|
|
218
|
+
label,
|
|
219
|
+
required: question.required ?? true,
|
|
220
|
+
config: { ...settings, isSecret },
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
type AnyQuestion = { id: string; type: string; label?: string; config?: ConfigQuestionSettings };
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Normalizes every config question in a list and checks that the others carry
|
|
229
|
+
* a label and no `config` block. Non-config questions pass through unchanged.
|
|
230
|
+
*/
|
|
231
|
+
export async function normalizeApprovalQuestions<Q extends AnyQuestion>(
|
|
232
|
+
questions: Q[],
|
|
233
|
+
ctx: { approvers: ApproversLike; callerAgentId?: string },
|
|
234
|
+
): Promise<
|
|
235
|
+
| { ok: true; questions: ((Q & { label: string }) | NormalizedConfigQuestion<Q>)[] }
|
|
236
|
+
| { ok: false; error: string }
|
|
237
|
+
> {
|
|
238
|
+
const out: ((Q & { label: string }) | NormalizedConfigQuestion<Q>)[] = [];
|
|
239
|
+
for (const question of questions) {
|
|
240
|
+
if (question.type !== "config") {
|
|
241
|
+
if (question.config !== undefined) {
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
error: `question "${question.id}": the config block is only valid on type "config"`,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (typeof question.label !== "string") {
|
|
248
|
+
return { ok: false, error: `question "${question.id}": label is required` };
|
|
249
|
+
}
|
|
250
|
+
out.push({ ...question, label: question.label });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const normalized = await normalizeConfigQuestion(question, ctx);
|
|
254
|
+
if (!normalized.ok) return normalized;
|
|
255
|
+
out.push(normalized.question);
|
|
256
|
+
}
|
|
257
|
+
return { ok: true, questions: out };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A stored config question, as read back from `approval_requests.questions`. */
|
|
261
|
+
export type StoredConfigQuestion = {
|
|
262
|
+
id: string;
|
|
263
|
+
type: "config";
|
|
264
|
+
label: string;
|
|
265
|
+
required?: boolean;
|
|
266
|
+
config: NormalizedConfigSettings;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export function isConfigQuestion(question: {
|
|
270
|
+
type: string;
|
|
271
|
+
config?: unknown;
|
|
272
|
+
}): question is StoredConfigQuestion {
|
|
273
|
+
return question.type === "config" && typeof question.config === "object" && !!question.config;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Validates one raw answer against D6 and the config API's rules. Errors name
|
|
278
|
+
* the question and the rule, never the value.
|
|
279
|
+
*/
|
|
280
|
+
export function parseConfigAnswer(
|
|
281
|
+
question: StoredConfigQuestion,
|
|
282
|
+
raw: unknown,
|
|
283
|
+
state: ConfigState,
|
|
284
|
+
): { ok: true; answer: ConfigAnswer } | { ok: false; error: string } {
|
|
285
|
+
const fail = (reason: string) => ({
|
|
286
|
+
ok: false as const,
|
|
287
|
+
error: `config question "${question.id}": ${reason}`,
|
|
288
|
+
});
|
|
289
|
+
const required = question.required !== false;
|
|
290
|
+
if (raw === undefined || raw === null) {
|
|
291
|
+
return required ? fail("an answer is required") : { ok: true, answer: { action: "skip" } };
|
|
292
|
+
}
|
|
293
|
+
const parsed = ConfigAnswerSchema.safeParse(raw);
|
|
294
|
+
if (!parsed.success) {
|
|
295
|
+
return fail(
|
|
296
|
+
'answer must be { action: "set", value } , { action: "keep" } or { action: "skip" }',
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
const answer = parsed.data;
|
|
300
|
+
if (answer.action === "keep") {
|
|
301
|
+
return state.exists ? { ok: true, answer } : fail("cannot keep a key that is not set");
|
|
302
|
+
}
|
|
303
|
+
if (answer.action === "skip") {
|
|
304
|
+
return required ? fail("a required question cannot be skipped") : { ok: true, answer };
|
|
305
|
+
}
|
|
306
|
+
if (answer.value.trim().length === 0) return fail("value must not be empty");
|
|
307
|
+
const { config } = question;
|
|
308
|
+
const isSecret = config.isSecret || state.isSecret;
|
|
309
|
+
if (!isSecret && config.options && !config.options.some((o) => o.value === answer.value)) {
|
|
310
|
+
return fail("value must be one of options");
|
|
311
|
+
}
|
|
312
|
+
const error = validateConfigEntry({
|
|
313
|
+
scope: config.scope,
|
|
314
|
+
scopeId: config.scopeId ?? null,
|
|
315
|
+
key: config.key,
|
|
316
|
+
value: answer.value,
|
|
317
|
+
});
|
|
318
|
+
if (error) return fail(isSecret ? `value rejected by ${config.key} validation` : error);
|
|
319
|
+
return { ok: true, answer };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function result(
|
|
323
|
+
question: StoredConfigQuestion,
|
|
324
|
+
status: ConfigResult["status"],
|
|
325
|
+
isSecret: boolean,
|
|
326
|
+
updatedAt: string | null,
|
|
327
|
+
value?: string,
|
|
328
|
+
): ConfigResult {
|
|
329
|
+
const { key, scope, scopeId } = question.config;
|
|
330
|
+
return {
|
|
331
|
+
key,
|
|
332
|
+
scope,
|
|
333
|
+
scopeId: scope === "global" ? null : (scopeId ?? null),
|
|
334
|
+
isSecret,
|
|
335
|
+
status,
|
|
336
|
+
updatedAt,
|
|
337
|
+
...(!isSecret && value !== undefined ? { value } : {}),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function skippedConfigResult(question: StoredConfigQuestion): ConfigResult {
|
|
342
|
+
return result(question, "skipped", question.config.isSecret, null);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** `keep`: nothing is written. Null when the row disappeared since validation. */
|
|
346
|
+
export async function keptConfigResult(
|
|
347
|
+
question: StoredConfigQuestion,
|
|
348
|
+
): Promise<ConfigResult | null> {
|
|
349
|
+
const row = await readConfigRow(question.config);
|
|
350
|
+
if (!row) return null;
|
|
351
|
+
const isSecret = question.config.isSecret || row.isSecret;
|
|
352
|
+
return result(question, "kept", isSecret, row.updatedAt, row.value);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* `set`: writes the value inside the caller's transaction. Carries the row's
|
|
357
|
+
* `envPath` and `description` forward (the upsert would null them) and keeps
|
|
358
|
+
* an existing secret secret. The caller runs the HTTP side effects after
|
|
359
|
+
* commit.
|
|
360
|
+
*/
|
|
361
|
+
export async function applyConfigAnswer(
|
|
362
|
+
question: StoredConfigQuestion,
|
|
363
|
+
value: string,
|
|
364
|
+
): Promise<ConfigResult> {
|
|
365
|
+
const { config } = question;
|
|
366
|
+
const existing = await readConfigRow(config);
|
|
367
|
+
const isSecret = config.isSecret || existing?.isSecret === true;
|
|
368
|
+
const written = await upsertSwarmConfigWithPolicyMirror({
|
|
369
|
+
scope: config.scope,
|
|
370
|
+
scopeId: config.scope === "global" ? null : (config.scopeId ?? null),
|
|
371
|
+
key: config.key,
|
|
372
|
+
value,
|
|
373
|
+
isSecret,
|
|
374
|
+
envPath: existing?.envPath ?? null,
|
|
375
|
+
description: existing?.description ?? null,
|
|
376
|
+
});
|
|
377
|
+
return result(question, "set", isSecret, written.lastUpdatedAt, value);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** One line for the hitl-follow-up task text. Never carries a secret. */
|
|
381
|
+
export function formatConfigResult(value: unknown): string | null {
|
|
382
|
+
const parsed = ConfigResultSchema.safeParse(value);
|
|
383
|
+
if (!parsed.success) return null;
|
|
384
|
+
const r = parsed.data;
|
|
385
|
+
const where = `${r.scope}${r.scopeId ? `:${r.scopeId}` : ""}`;
|
|
386
|
+
const updated = r.updatedAt ? `, updated ${r.updatedAt}` : "";
|
|
387
|
+
const shown = !r.isSecret && r.value !== undefined ? ` = ${r.value}` : "";
|
|
388
|
+
return `${r.key} (${where}, ${r.isSecret ? "secret" : "plain"}): ${r.status}${updated}${shown}`;
|
|
389
|
+
}
|
|
@@ -345,6 +345,29 @@ function utcDayBounds(now: Date): { start: string; end: string } {
|
|
|
345
345
|
return { start: start.toISOString(), end: new Date(start.getTime() + 86_400_000).toISOString() };
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
+
/**
|
|
349
|
+
* True when `runId` is the workflow's only setup-failure run so far this UTC
|
|
350
|
+
* day, top-level or child. Call it in the transaction that inserted `runId`:
|
|
351
|
+
* transactions serialize on the write lock, so of several concurrent failures
|
|
352
|
+
* exactly one sees no other and alerts.
|
|
353
|
+
*/
|
|
354
|
+
export async function isFirstWorkflowPreflightFailureToday(
|
|
355
|
+
workflowId: string,
|
|
356
|
+
runId: string,
|
|
357
|
+
now: Date = new Date(),
|
|
358
|
+
): Promise<boolean> {
|
|
359
|
+
const bounds = utcDayBounds(now);
|
|
360
|
+
const row = await getDbClient().get<{ found: number }>(
|
|
361
|
+
`SELECT 1 AS found FROM workflow_runs
|
|
362
|
+
WHERE workflowId = ? AND id != ? AND status = 'failed'
|
|
363
|
+
AND error LIKE 'needs_setup:%'
|
|
364
|
+
AND startedAt >= ? AND startedAt < ?
|
|
365
|
+
LIMIT 1`,
|
|
366
|
+
[workflowId, runId, bounds.start, bounds.end],
|
|
367
|
+
);
|
|
368
|
+
return row == null;
|
|
369
|
+
}
|
|
370
|
+
|
|
348
371
|
/** Record at most one copy of the same setup failure on a schedule per UTC day. */
|
|
349
372
|
export async function recordSchedulePreflightFailure(
|
|
350
373
|
scheduleId: string,
|
|
@@ -376,7 +399,11 @@ export async function recordSchedulePreflightFailure(
|
|
|
376
399
|
});
|
|
377
400
|
}
|
|
378
401
|
|
|
379
|
-
/**
|
|
402
|
+
/**
|
|
403
|
+
* Create and finish at most one failed top-level preflight run per workflow
|
|
404
|
+
* and UTC day. `recorded` is true when the caller should alert: this call
|
|
405
|
+
* inserted the run and no child failure run of the workflow came first today.
|
|
406
|
+
*/
|
|
380
407
|
export async function recordWorkflowPreflightFailure(input: {
|
|
381
408
|
workflowId: string;
|
|
382
409
|
triggerType: "schedule" | "manual" | "event" | "api";
|
|
@@ -388,9 +415,11 @@ export async function recordWorkflowPreflightFailure(input: {
|
|
|
388
415
|
const now = input.now ?? new Date();
|
|
389
416
|
const bounds = utcDayBounds(now);
|
|
390
417
|
return await getDbClient().transaction(async (tx) => {
|
|
418
|
+
// Child runs record their own linked failure (`startWorkflowExecution`);
|
|
419
|
+
// a top-level trigger never reuses one.
|
|
391
420
|
const existing = await tx.get<{ id: string }>(
|
|
392
421
|
`SELECT id FROM workflow_runs
|
|
393
|
-
WHERE workflowId = ? AND status = 'failed'
|
|
422
|
+
WHERE workflowId = ? AND status = 'failed' AND parentStepId IS NULL
|
|
394
423
|
AND error LIKE 'needs_setup:%'
|
|
395
424
|
AND startedAt >= ? AND startedAt < ?
|
|
396
425
|
ORDER BY startedAt ASC LIMIT 1`,
|
|
@@ -417,6 +446,11 @@ export async function recordWorkflowPreflightFailure(input: {
|
|
|
417
446
|
timestamp,
|
|
418
447
|
],
|
|
419
448
|
);
|
|
420
|
-
|
|
449
|
+
// Claimed in this transaction, which also sees every child failure run
|
|
450
|
+
// committed before it: concurrent failures alert exactly once a day.
|
|
451
|
+
return {
|
|
452
|
+
runId,
|
|
453
|
+
recorded: await isFirstWorkflowPreflightFailureToday(input.workflowId, runId, now),
|
|
454
|
+
};
|
|
421
455
|
});
|
|
422
456
|
}
|
|
@@ -16,8 +16,13 @@ export type SessionReadState = "unread" | "read" | "later" | "done";
|
|
|
16
16
|
export interface SessionFilters {
|
|
17
17
|
/** Restrict roots to these task sources. */
|
|
18
18
|
source?: string[];
|
|
19
|
-
/** Leave out roots of these task sources (the sessions v2 source filter). */
|
|
19
|
+
/** Leave out roots of these task sources (the older sessions v2 source filter). */
|
|
20
20
|
hideSource?: string[];
|
|
21
|
+
/**
|
|
22
|
+
* Keep only roots of these task sources (the sessions v2 source filter).
|
|
23
|
+
* Unlike `source`, an empty list matches no root.
|
|
24
|
+
*/
|
|
25
|
+
showSource?: string[];
|
|
21
26
|
/** Match root task text or title with a case-insensitive substring. */
|
|
22
27
|
q?: string;
|
|
23
28
|
/** Match the root requester, excluding NULL requester rows. */
|
|
@@ -50,13 +55,17 @@ export interface SessionFilters {
|
|
|
50
55
|
|
|
51
56
|
type SqlFragment = { sql: string; params: (string | number | null)[] };
|
|
52
57
|
|
|
53
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Shared read-state rule with bound ISO timestamps for the list and tree.
|
|
60
|
+
* Done holds through agent activity (progress, status changes, child tasks):
|
|
61
|
+
* only `a.reopensDone`, counted in `sessionSummaryQuery`, brings it back.
|
|
62
|
+
*/
|
|
54
63
|
function sessionReadStateSql(now = new Date().toISOString()): SqlFragment {
|
|
55
64
|
return {
|
|
56
65
|
sql: `CASE
|
|
57
66
|
WHEN s.id IS NULL THEN CASE WHEN a.lastActivityAt > ? THEN 'unread' ELSE 'read' END
|
|
58
67
|
WHEN s.status = 'dismissed' THEN 'done'
|
|
59
|
-
WHEN s.status = 'done' THEN CASE WHEN a.
|
|
68
|
+
WHEN s.status = 'done' THEN CASE WHEN a.reopensDone > 0 THEN 'unread' ELSE 'done' END
|
|
60
69
|
WHEN s.status = 'snoozed' THEN CASE WHEN s.snoozeUntil > ? THEN 'later' ELSE 'unread' END
|
|
61
70
|
WHEN a.lastActivityAt > COALESCE(s.lastReadAt, s.lastUpdatedAt) THEN 'unread'
|
|
62
71
|
ELSE 'read'
|
|
@@ -100,6 +109,14 @@ export function sessionRootFilter(opts: SessionFilters = {}): SqlFragment {
|
|
|
100
109
|
const hidden = opts.hideSource?.filter(Boolean) ?? [];
|
|
101
110
|
if (hidden.length)
|
|
102
111
|
add({ sql: `r.source NOT IN (${hidden.map(() => "?").join(",")})`, params: hidden });
|
|
112
|
+
if (opts.showSource) {
|
|
113
|
+
const shown = opts.showSource.filter(Boolean);
|
|
114
|
+
add(
|
|
115
|
+
shown.length
|
|
116
|
+
? { sql: `r.source IN (${shown.map(() => "?").join(",")})`, params: shown }
|
|
117
|
+
: { sql: "0", params: [] },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
103
120
|
if (opts.q?.trim()) {
|
|
104
121
|
const like = `%${opts.q.trim().toLowerCase()}%`;
|
|
105
122
|
add({
|
|
@@ -152,6 +169,35 @@ function hasSessionChainFilter(opts: SessionFilters): boolean {
|
|
|
152
169
|
|
|
153
170
|
const runningSql = `latestStatus NOT IN (${TERMINAL_TASK_STATUSES.map((s) => `'${s}'`).join(",")})`;
|
|
154
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Sources of tasks that agents and the server create: delegation and scripts
|
|
174
|
+
* (`mcp`), follow-ups (`system`), deferred wake-ups (`schedule`) and workflow
|
|
175
|
+
* steps. Agents also set `requestedByUserId` explicitly on these, so a requester
|
|
176
|
+
* alone does not mark a human message.
|
|
177
|
+
*/
|
|
178
|
+
const AGENT_TASK_SOURCES = ["mcp", "system", "schedule", "workflow"] as const;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Per chain row, over `chain` and the viewer's Done row `d`: whether the row
|
|
182
|
+
* needs the user since they marked the session Done. That is a human message
|
|
183
|
+
* (a task attributed to a person directly, not copied from its parent, from a
|
|
184
|
+
* human channel), a person's steering message on a chain task (steer or
|
|
185
|
+
* queue, not cancelled), or a pending approval, created after the Done mark.
|
|
186
|
+
* Steering `created_at` and the Done mark's `lastUpdatedAt` are both written
|
|
187
|
+
* as `%Y-%m-%dT%H:%M:%fZ`, so they compare as text.
|
|
188
|
+
*/
|
|
189
|
+
const reopensDoneSql = `(d.id IS NOT NULL AND (
|
|
190
|
+
(chain.createdAt > d.lastUpdatedAt
|
|
191
|
+
AND chain.requestedByUserId IS NOT NULL
|
|
192
|
+
AND chain.requestedByUserIdInherited = 0
|
|
193
|
+
AND COALESCE(chain.source, '') NOT IN (${AGENT_TASK_SOURCES.map((s) => `'${s}'`).join(",")}))
|
|
194
|
+
OR EXISTS (SELECT 1 FROM task_steering_messages sm
|
|
195
|
+
WHERE sm.task_id = chain.id AND sm.created_by_kind = 'user'
|
|
196
|
+
AND sm.status != 'cancelled' AND sm.created_at > d.lastUpdatedAt)
|
|
197
|
+
OR EXISTS (SELECT 1 FROM approval_requests ar
|
|
198
|
+
WHERE ar.sourceTaskId = chain.id AND ar.status = 'pending' AND ar.createdAt > d.lastUpdatedAt)
|
|
199
|
+
))`;
|
|
200
|
+
|
|
155
201
|
/**
|
|
156
202
|
* A session starts at a parentTaskId IS NULL root. One recursive CTE computes
|
|
157
203
|
* chain-wide latest activity for list ordering. Root filters precede recursion.
|
|
@@ -162,7 +208,7 @@ export function sessionSummaryQuery(opts: SessionFilters = {}): SqlFragment {
|
|
|
162
208
|
const read = opts.viewer?.userId ? sessionReadStateSql(opts.now) : { sql: "NULL", params: [] };
|
|
163
209
|
const params = [...root.params];
|
|
164
210
|
if (opts.withUserId) params.push(opts.withUserId);
|
|
165
|
-
params.push(...read.params, opts.viewer?.userId ?? null);
|
|
211
|
+
params.push(opts.viewer?.userId ?? null, ...read.params, opts.viewer?.userId ?? null);
|
|
166
212
|
const filters: string[] = [];
|
|
167
213
|
if (opts.triage === "unread") filters.push("readState = 'unread'");
|
|
168
214
|
else if (opts.triage === "later" || opts.triage === "done") {
|
|
@@ -182,19 +228,27 @@ export function sessionSummaryQuery(opts: SessionFilters = {}): SqlFragment {
|
|
|
182
228
|
return {
|
|
183
229
|
// SQLite takes the bare status from the row with MAX(lastUpdatedAt).
|
|
184
230
|
// Keep this as the only MIN/MAX aggregate so status follows activity.
|
|
185
|
-
|
|
186
|
-
|
|
231
|
+
// `d` is the viewer's Done row, so `reopensDone` can compare chain rows
|
|
232
|
+
// against the Done mark (SUM keeps MAX the only MIN/MAX aggregate).
|
|
233
|
+
sql: `WITH RECURSIVE chain(root_id, id, lastUpdatedAt, status, requestedByUserId,
|
|
234
|
+
createdAt, source, requestedByUserIdInherited) AS (
|
|
235
|
+
SELECT r.id, r.id, r.lastUpdatedAt, r.status, r.requestedByUserId,
|
|
236
|
+
r.createdAt, r.source, r.requestedByUserIdInherited
|
|
187
237
|
FROM agent_tasks r WHERE ${root.sql}
|
|
188
238
|
UNION ALL
|
|
189
|
-
SELECT c.root_id, t.id, t.lastUpdatedAt, t.status, t.requestedByUserId
|
|
239
|
+
SELECT c.root_id, t.id, t.lastUpdatedAt, t.status, t.requestedByUserId,
|
|
240
|
+
t.createdAt, t.source, t.requestedByUserIdInherited
|
|
190
241
|
FROM chain c JOIN agent_tasks t ON t.parentTaskId = c.id
|
|
191
242
|
), agg AS (
|
|
192
|
-
SELECT root_id, COUNT(*) AS chainCount,
|
|
193
|
-
status AS latestStatus, MAX(lastUpdatedAt) AS lastActivityAt,
|
|
194
|
-
${opts.withUserId ? "SUM(requestedByUserId = ?) AS withUser," : ""}
|
|
243
|
+
SELECT chain.root_id, COUNT(*) AS chainCount,
|
|
244
|
+
chain.status AS latestStatus, MAX(chain.lastUpdatedAt) AS lastActivityAt,
|
|
245
|
+
${opts.withUserId ? "SUM(chain.requestedByUserId = ?) AS withUser," : ""}
|
|
195
246
|
SUM(EXISTS (SELECT 1 FROM approval_requests ar
|
|
196
|
-
WHERE ar.sourceTaskId = chain.id AND ar.status = 'pending')) AS pendingApproval
|
|
197
|
-
|
|
247
|
+
WHERE ar.sourceTaskId = chain.id AND ar.status = 'pending')) AS pendingApproval,
|
|
248
|
+
SUM(${reopensDoneSql}) AS reopensDone
|
|
249
|
+
FROM chain LEFT JOIN inbox_item_state d
|
|
250
|
+
ON d.userId = ? AND d.itemType = 'to_read' AND d.itemId = chain.root_id AND d.status = 'done'
|
|
251
|
+
GROUP BY chain.root_id
|
|
198
252
|
), state AS (
|
|
199
253
|
SELECT a.*, ${read.sql} AS readState, s.snoozeUntil
|
|
200
254
|
FROM agg a LEFT JOIN inbox_item_state s
|
|
@@ -341,7 +395,7 @@ export interface SessionTree {
|
|
|
341
395
|
export async function getSessionTree(
|
|
342
396
|
actor: { kind: "bypass" } | { kind: "user"; userId: string | null },
|
|
343
397
|
now?: string,
|
|
344
|
-
opts: { mine?: boolean; hideSource?: string[] } = {},
|
|
398
|
+
opts: { mine?: boolean; hideSource?: string[]; showSource?: string[] } = {},
|
|
345
399
|
): Promise<SessionTree> {
|
|
346
400
|
// No source filter in SQL: the source counts must include hidden sources,
|
|
347
401
|
// so the filter menu can offer them back. The loop skips them instead.
|
|
@@ -373,6 +427,7 @@ export async function getSessionTree(
|
|
|
373
427
|
sources: [],
|
|
374
428
|
};
|
|
375
429
|
const hidden = new Set(opts.hideSource);
|
|
430
|
+
const shown = opts.showSource ? new Set(opts.showSource) : null;
|
|
376
431
|
const sources = new Map<string, number>();
|
|
377
432
|
const groups = new Map<string, SessionTreeGroup>();
|
|
378
433
|
const add = (section: SessionSectionCounts, row: (typeof rows)[number]) => {
|
|
@@ -383,7 +438,7 @@ export async function getSessionTree(
|
|
|
383
438
|
};
|
|
384
439
|
for (const row of rows) {
|
|
385
440
|
sources.set(row.source, (sources.get(row.source) ?? 0) + 1);
|
|
386
|
-
if (hidden.has(row.source)) continue;
|
|
441
|
+
if (hidden.has(row.source) || (shown && !shown.has(row.source))) continue;
|
|
387
442
|
const key = sessionGroupKey(row.key);
|
|
388
443
|
const noGroup =
|
|
389
444
|
key === "shared/" || (actor.kind === "user" && key === `personal/${actor.userId}/`);
|
|
@@ -425,7 +480,10 @@ const UNREAD_CURSOR = "1970-01-01T00:00:00.000Z";
|
|
|
425
480
|
*/
|
|
426
481
|
export async function triageSessionList(
|
|
427
482
|
userId: string,
|
|
428
|
-
filters: Pick<
|
|
483
|
+
filters: Pick<
|
|
484
|
+
SessionFilters,
|
|
485
|
+
"triage" | "noGroup" | "groupKey" | "mine" | "hideSource" | "showSource" | "now"
|
|
486
|
+
>,
|
|
429
487
|
action: SessionListTriage,
|
|
430
488
|
): Promise<number> {
|
|
431
489
|
const now = filters.now ?? new Date().toISOString();
|