@desplega.ai/agent-swarm 1.92.2 → 1.94.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 +2 -2
- package/openapi.json +242 -3
- package/package.json +5 -5
- package/src/be/db.ts +152 -11
- package/src/be/memory/boot-reembed.ts +0 -1
- package/src/be/memory/providers/sqlite-store.ts +42 -25
- package/src/be/memory/raters/llm-client.ts +12 -5
- package/src/be/memory/types.ts +3 -0
- package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
- package/src/be/migrations/089_harness_variant.sql +2 -0
- package/src/be/migrations/090_model_tiers.sql +2 -0
- package/src/be/migrations/091_seed_swarm_operations_metrics.sql +12 -0
- package/src/be/migrations/092_metrics_dashboard_combobox_filters.sql +68 -0
- package/src/be/migrations/093_slack_message_tracking.sql +6 -0
- package/src/be/migrations/runner.ts +52 -0
- package/src/be/modelsdev-cache.json +3264 -1166
- package/src/be/scripts/boot-reembed.ts +74 -0
- package/src/be/scripts/db.ts +19 -3
- package/src/be/seed/index.ts +1 -1
- package/src/be/seed/registry.ts +2 -2
- package/src/be/seed/runner.ts +5 -5
- package/src/be/seed/types.ts +6 -1
- package/src/be/seed-pricing.ts +2 -0
- package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
- package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
- package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
- package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
- package/src/be/seed-scripts/index.ts +8 -7
- package/src/be/skill-sync.ts +28 -179
- package/src/commands/runner.ts +197 -10
- package/src/http/api-keys.ts +42 -0
- package/src/http/index.ts +13 -2
- package/src/http/mcp-bridge.ts +1 -1
- package/src/http/memory.ts +23 -24
- package/src/http/metrics.ts +55 -6
- package/src/http/schedules.ts +16 -15
- package/src/http/script-runs.ts +7 -1
- package/src/http/scripts.ts +147 -1
- package/src/http/tasks.ts +17 -6
- package/src/model-tiers.ts +140 -0
- package/src/providers/claude-adapter.ts +33 -1
- package/src/providers/claude-managed-adapter.ts +3 -0
- package/src/providers/claude-managed-models.ts +16 -0
- package/src/providers/codex-adapter.ts +8 -1
- package/src/providers/codex-models.ts +1 -0
- package/src/providers/codex-oauth/auth-json.ts +1 -0
- package/src/providers/harness-version.ts +7 -0
- package/src/providers/opencode-adapter.ts +12 -4
- package/src/providers/pi-mono-adapter.ts +90 -8
- package/src/providers/types.ts +2 -0
- package/src/scheduler/scheduler.ts +22 -34
- package/src/scripts-runtime/egress-secrets.ts +83 -0
- package/src/scripts-runtime/eval-harness.ts +4 -0
- package/src/scripts-runtime/executors/types.ts +7 -0
- package/src/scripts-runtime/loader.ts +2 -0
- package/src/server-user.ts +8 -2
- package/src/slack/channel-join.ts +41 -0
- package/src/slack/responses.ts +39 -11
- package/src/slack/watcher.ts +121 -8
- package/src/tests/additive-buffer.test.ts +0 -1
- package/src/tests/agents-list-model-display.test.ts +13 -0
- package/src/tests/api-key-tracking.test.ts +113 -0
- package/src/tests/approval-requests.test.ts +0 -6
- package/src/tests/aws-error-classifier.test.ts +148 -0
- package/src/tests/claude-managed-adapter.test.ts +12 -0
- package/src/tests/claude-managed-setup.test.ts +0 -4
- package/src/tests/codex-pool.test.ts +2 -6
- package/src/tests/context-window.test.ts +7 -0
- package/src/tests/http-api-integration.test.ts +23 -6
- package/src/tests/memory-edges.test.ts +0 -2
- package/src/tests/memory-rate-endpoint.test.ts +0 -2
- package/src/tests/memory-rater-e2e.test.ts +0 -2
- package/src/tests/memory-store.test.ts +19 -1
- package/src/tests/memory.test.ts +51 -0
- package/src/tests/metrics-http.test.ts +137 -3
- package/src/tests/migration-046-budgets.test.ts +33 -0
- package/src/tests/migration-runner-regressions.test.ts +69 -0
- package/src/tests/model-control.test.ts +162 -46
- package/src/tests/opencode-adapter.test.ts +9 -0
- package/src/tests/pi-mono-adapter.test.ts +319 -0
- package/src/tests/providers/pi-cost.test.ts +9 -0
- package/src/tests/reload-config.test.ts +33 -17
- package/src/tests/runner-fallback-output.test.ts +50 -0
- package/src/tests/runner-skills-refresh.test.ts +216 -46
- package/src/tests/script-runs-http.test.ts +7 -1
- package/src/tests/scripts-boot-reembed.test.ts +163 -0
- package/src/tests/scripts-embeddings.test.ts +90 -0
- package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
- package/src/tests/seed-scripts.test.ts +13 -1
- package/src/tests/seed.test.ts +26 -1
- package/src/tests/session-attach.test.ts +6 -6
- package/src/tests/session-costs-model-key-normalize.test.ts +2 -0
- package/src/tests/skill-fs-writer.test.ts +250 -0
- package/src/tests/slack-attachments-block.test.ts +0 -1
- package/src/tests/slack-blocks.test.ts +0 -1
- package/src/tests/slack-channel-join.test.ts +80 -0
- package/src/tests/slack-identity-resolution.test.ts +0 -1
- package/src/tests/slack-watcher.test.ts +66 -0
- package/src/tests/structured-output.test.ts +0 -2
- package/src/tests/use-dismissible-card.test.ts +0 -4
- package/src/tests/workflow-agent-task.test.ts +5 -2
- package/src/tests/workflow-validation-port-routing.test.ts +181 -0
- package/src/tools/memory-get.ts +11 -0
- package/src/tools/memory-search.ts +18 -0
- package/src/tools/schedules/create-schedule.ts +71 -70
- package/src/tools/schedules/update-schedule.ts +43 -31
- package/src/tools/send-task.ts +16 -5
- package/src/tools/slack-post.ts +18 -15
- package/src/tools/slack-read.ts +9 -11
- package/src/tools/slack-reply.ts +18 -15
- package/src/tools/slack-start-thread.ts +17 -14
- package/src/tools/task-action.ts +11 -3
- package/src/types.ts +40 -0
- package/src/utils/aws-error-classifier.ts +97 -0
- package/src/utils/context-window.ts +5 -0
- package/src/utils/credentials.test.ts +68 -0
- package/src/utils/credentials.ts +66 -5
- package/src/utils/pretty-print.ts +25 -10
- package/src/utils/skill-fs-writer.ts +220 -0
- package/src/utils/skills-refresh.ts +123 -40
- package/src/workflows/engine.ts +3 -2
- package/src/workflows/executors/agent-task.ts +3 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { EgressSecretEntry } from "./executors/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hardcoded allowlist mapping env-var names to the hosts where egress
|
|
5
|
+
* substitution is permitted. Adding a new entry here is a security-boundary
|
|
6
|
+
* decision — it lets scripts authenticate to that host without the caller
|
|
7
|
+
* passing the secret explicitly.
|
|
8
|
+
*/
|
|
9
|
+
const EGRESS_ALLOWLIST: Record<string, string[]> = {
|
|
10
|
+
GITHUB_TOKEN: ["api.github.com"],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function buildEgressSecrets(): EgressSecretEntry[] {
|
|
14
|
+
const entries: EgressSecretEntry[] = [];
|
|
15
|
+
for (const [envKey, hosts] of Object.entries(EGRESS_ALLOWLIST)) {
|
|
16
|
+
const value = process.env[envKey];
|
|
17
|
+
if (!value) continue;
|
|
18
|
+
entries.push({
|
|
19
|
+
placeholder: `[REDACTED:${envKey}]`,
|
|
20
|
+
hosts,
|
|
21
|
+
value,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return entries;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function patchFetchWithEgressSubstitution(secrets: EgressSecretEntry[]): void {
|
|
28
|
+
if (secrets.length === 0) return;
|
|
29
|
+
|
|
30
|
+
const byPlaceholder = new Map<string, EgressSecretEntry>();
|
|
31
|
+
for (const entry of secrets) {
|
|
32
|
+
byPlaceholder.set(entry.placeholder, entry);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const originalFetch = globalThis.fetch;
|
|
36
|
+
|
|
37
|
+
globalThis.fetch = function patchedFetch(
|
|
38
|
+
input: string | URL | Request,
|
|
39
|
+
init?: RequestInit,
|
|
40
|
+
): Promise<Response> {
|
|
41
|
+
let hostname: string;
|
|
42
|
+
try {
|
|
43
|
+
const url = input instanceof Request ? input.url : input instanceof URL ? input.href : input;
|
|
44
|
+
hostname = new URL(url).hostname;
|
|
45
|
+
} catch {
|
|
46
|
+
return originalFetch(input, init);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const headers = new Headers(input instanceof Request ? input.headers : init?.headers);
|
|
50
|
+
|
|
51
|
+
let modified = false;
|
|
52
|
+
const newHeaders = new Headers();
|
|
53
|
+
|
|
54
|
+
for (const [key, rawValue] of headers.entries()) {
|
|
55
|
+
let value = rawValue;
|
|
56
|
+
for (const [placeholder, entry] of byPlaceholder) {
|
|
57
|
+
if (value.includes(placeholder) && entry.hosts.includes(hostname)) {
|
|
58
|
+
value = value.split(placeholder).join(entry.value);
|
|
59
|
+
modified = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
newHeaders.set(key, value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!modified) return originalFetch(input, init);
|
|
66
|
+
|
|
67
|
+
const mergedInit: RequestInit = {
|
|
68
|
+
...(input instanceof Request
|
|
69
|
+
? {
|
|
70
|
+
method: input.method,
|
|
71
|
+
body: input.body,
|
|
72
|
+
redirect: input.redirect,
|
|
73
|
+
signal: input.signal,
|
|
74
|
+
}
|
|
75
|
+
: {}),
|
|
76
|
+
...init,
|
|
77
|
+
headers: newHeaders,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const url = input instanceof Request ? input.url : input;
|
|
81
|
+
return originalFetch(url, mergedInit);
|
|
82
|
+
} as typeof fetch;
|
|
83
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildCtx } from "./ctx";
|
|
2
|
+
import { patchFetchWithEgressSubstitution } from "./egress-secrets";
|
|
2
3
|
import type { SwarmConfigPayload } from "./executors/types";
|
|
3
4
|
import { SwarmConfig } from "./swarm-config";
|
|
4
5
|
|
|
@@ -84,6 +85,9 @@ try {
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
const payload = JSON.parse(stdin) as SwarmConfigPayload;
|
|
88
|
+
if (payload.egressSecrets?.length) {
|
|
89
|
+
patchFetchWithEgressSubstitution(payload.egressSecrets);
|
|
90
|
+
}
|
|
87
91
|
const swarmConfig = new SwarmConfig(payload);
|
|
88
92
|
const rawArgs = JSON.parse(await Bun.file(requiredEnv("SWARM_SCRIPT_ARGS_FILE")).text());
|
|
89
93
|
// Accept both shapes: callers may pass an already-serialized JSON string.
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export type ScriptFsMode = "none" | "workspace-rw";
|
|
2
2
|
|
|
3
|
+
export type EgressSecretEntry = {
|
|
4
|
+
placeholder: string;
|
|
5
|
+
hosts: string[];
|
|
6
|
+
value: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
3
9
|
export type SwarmConfigPayload = {
|
|
4
10
|
system: {
|
|
5
11
|
apiKey: { value: string; isSecret: true };
|
|
@@ -7,6 +13,7 @@ export type SwarmConfigPayload = {
|
|
|
7
13
|
mcpBaseUrl: { value: string; isSecret: false };
|
|
8
14
|
};
|
|
9
15
|
user: Record<string, { value: string; isSecret: boolean }>;
|
|
16
|
+
egressSecrets?: EgressSecretEntry[];
|
|
10
17
|
};
|
|
11
18
|
|
|
12
19
|
export type ScriptResourcePolicy = {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getApiKey } from "../utils/api-key";
|
|
2
2
|
import { scrubObject, scrubSecrets } from "../utils/secret-scrubber";
|
|
3
|
+
import { buildEgressSecrets } from "./egress-secrets";
|
|
3
4
|
import { getScriptExecutor } from "./executors/registry";
|
|
4
5
|
import {
|
|
5
6
|
DEFAULT_SCRIPT_RESOURCES,
|
|
@@ -44,6 +45,7 @@ function buildConfigPayload(input: RunScriptInput): SwarmConfigPayload {
|
|
|
44
45
|
},
|
|
45
46
|
},
|
|
46
47
|
user: input.userConfig ?? {},
|
|
48
|
+
egressSecrets: buildEgressSecrets(),
|
|
47
49
|
};
|
|
48
50
|
}
|
|
49
51
|
|
package/src/server-user.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
import pkg from "../package.json";
|
|
4
|
+
import { ModelTierSchema } from "./model-tiers";
|
|
4
5
|
import {
|
|
5
6
|
cancelTaskHandler,
|
|
6
7
|
cancelTaskInputSchema,
|
|
@@ -28,9 +29,14 @@ const userSendTaskInputSchema = z.object({
|
|
|
28
29
|
tags: z.array(z.string()).optional().describe("Tags for filtering (e.g., ['urgent'])."),
|
|
29
30
|
priority: z.number().int().min(0).max(100).optional().describe("Priority 0-100 (default: 50)."),
|
|
30
31
|
model: z
|
|
31
|
-
.
|
|
32
|
+
.string()
|
|
33
|
+
.trim()
|
|
34
|
+
.min(1)
|
|
32
35
|
.optional()
|
|
33
|
-
.describe("
|
|
36
|
+
.describe("Concrete model override interpreted by the assignee's harness/provider."),
|
|
37
|
+
modelTier: ModelTierSchema.optional().describe(
|
|
38
|
+
"Portable model tier: 'smol', 'regular', 'smart', or 'ultra'. Resolved by the assignee's harness/provider.",
|
|
39
|
+
),
|
|
34
40
|
});
|
|
35
41
|
|
|
36
42
|
export function createUserServer(user: User): McpServer {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { WebClient } from "@slack/web-api";
|
|
2
|
+
|
|
3
|
+
// @slack/web-api platform errors set message to "An API error occurred: <code>"
|
|
4
|
+
// and store the raw Slack API code at error.data.error.
|
|
5
|
+
function slackCode(error: unknown): string | undefined {
|
|
6
|
+
if (!(error instanceof Error)) return undefined;
|
|
7
|
+
const d = (error as { data?: { error?: unknown } }).data;
|
|
8
|
+
return typeof d?.error === "string" ? d.error : undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wraps a Slack API call with automatic channel join for public channels.
|
|
13
|
+
*
|
|
14
|
+
* On not_in_channel: calls conversations.join and retries the original call once.
|
|
15
|
+
* On private channel (method_not_supported_for_channel_type): throws a descriptive
|
|
16
|
+
* error telling the caller the bot must be /invite-d — it cannot self-join private channels.
|
|
17
|
+
*/
|
|
18
|
+
export async function withAutoJoin<T>(
|
|
19
|
+
client: WebClient,
|
|
20
|
+
channelId: string,
|
|
21
|
+
fn: () => Promise<T>,
|
|
22
|
+
): Promise<T> {
|
|
23
|
+
try {
|
|
24
|
+
return await fn();
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (slackCode(error) !== "not_in_channel") throw error;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
await client.conversations.join({ channel: channelId });
|
|
30
|
+
} catch (joinError) {
|
|
31
|
+
if (slackCode(joinError) === "method_not_supported_for_channel_type") {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Cannot access private channel ${channelId} — invite the bot with /invite @<bot-name> first.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
throw joinError;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return await fn();
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/slack/responses.ts
CHANGED
|
@@ -15,6 +15,20 @@ import {
|
|
|
15
15
|
// Re-export for backward compatibility
|
|
16
16
|
export { markdownToSlack } from "./blocks";
|
|
17
17
|
|
|
18
|
+
export type SlackUpdateResult = "ok" | "not_found" | "failed";
|
|
19
|
+
|
|
20
|
+
function classifySlackUpdateError(error: unknown): SlackUpdateResult {
|
|
21
|
+
const errorCode = (error as { data?: { error?: string } } | undefined)?.data?.error;
|
|
22
|
+
if (
|
|
23
|
+
errorCode === "message_not_found" ||
|
|
24
|
+
errorCode === "channel_not_found" ||
|
|
25
|
+
errorCode === "thread_not_found"
|
|
26
|
+
) {
|
|
27
|
+
return "not_found";
|
|
28
|
+
}
|
|
29
|
+
return "failed";
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
const isDev = process.env.ENV === "development";
|
|
19
33
|
|
|
20
34
|
/**
|
|
@@ -140,12 +154,12 @@ export async function updateProgressInPlace(
|
|
|
140
154
|
task: AgentTask,
|
|
141
155
|
progress: string,
|
|
142
156
|
messageTs: string,
|
|
143
|
-
): Promise<
|
|
157
|
+
): Promise<SlackUpdateResult> {
|
|
144
158
|
const app = getSlackApp();
|
|
145
|
-
if (!app || !task.slackChannelId || !task.agentId) return
|
|
159
|
+
if (!app || !task.slackChannelId || !task.agentId) return "failed";
|
|
146
160
|
|
|
147
161
|
const agent = getAgentById(task.agentId);
|
|
148
|
-
if (!agent) return
|
|
162
|
+
if (!agent) return "failed";
|
|
149
163
|
|
|
150
164
|
const blocks = buildProgressBlocks({ agentName: agent.name, taskId: task.id, progress });
|
|
151
165
|
|
|
@@ -157,10 +171,17 @@ export async function updateProgressInPlace(
|
|
|
157
171
|
// biome-ignore lint/suspicious/noExplicitAny: Block Kit objects
|
|
158
172
|
blocks: blocks as any,
|
|
159
173
|
});
|
|
160
|
-
return
|
|
174
|
+
return "ok";
|
|
161
175
|
} catch (error) {
|
|
162
|
-
|
|
163
|
-
|
|
176
|
+
const result = classifySlackUpdateError(error);
|
|
177
|
+
if (result === "not_found") {
|
|
178
|
+
console.warn(
|
|
179
|
+
`[Slack] Progress message missing for task ${task.id} ts=${messageTs}; will repost`,
|
|
180
|
+
);
|
|
181
|
+
} else {
|
|
182
|
+
console.error(`[Slack] Failed to update progress in-place:`, error);
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
164
185
|
}
|
|
165
186
|
}
|
|
166
187
|
|
|
@@ -233,9 +254,9 @@ export async function updateTreeMessage(
|
|
|
233
254
|
messageTs: string,
|
|
234
255
|
blocks: unknown[],
|
|
235
256
|
fallbackText: string,
|
|
236
|
-
): Promise<
|
|
257
|
+
): Promise<SlackUpdateResult> {
|
|
237
258
|
const app = getSlackApp();
|
|
238
|
-
if (!app) return
|
|
259
|
+
if (!app) return "failed";
|
|
239
260
|
|
|
240
261
|
try {
|
|
241
262
|
await app.client.chat.update({
|
|
@@ -245,10 +266,17 @@ export async function updateTreeMessage(
|
|
|
245
266
|
// biome-ignore lint/suspicious/noExplicitAny: Block Kit objects
|
|
246
267
|
blocks: blocks as any,
|
|
247
268
|
});
|
|
248
|
-
return
|
|
269
|
+
return "ok";
|
|
249
270
|
} catch (error) {
|
|
250
|
-
|
|
251
|
-
|
|
271
|
+
const result = classifySlackUpdateError(error);
|
|
272
|
+
if (result === "not_found") {
|
|
273
|
+
console.warn(
|
|
274
|
+
`[Slack] Tree message missing for channel=${channelId} ts=${messageTs}; will repost`,
|
|
275
|
+
);
|
|
276
|
+
} else {
|
|
277
|
+
console.error(`[Slack] Failed to update tree message:`, error);
|
|
278
|
+
}
|
|
279
|
+
return result;
|
|
252
280
|
}
|
|
253
281
|
}
|
|
254
282
|
|
package/src/slack/watcher.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
getInProgressSlackTasks,
|
|
6
6
|
getTaskAttachments,
|
|
7
7
|
getTaskById,
|
|
8
|
+
setSlackMessageTracking,
|
|
8
9
|
} from "../be/db";
|
|
9
10
|
import type { AgentTask } from "../types";
|
|
10
11
|
import { getSlackApp } from "./app";
|
|
@@ -76,6 +77,15 @@ export function registerTreeMessage(
|
|
|
76
77
|
// Also register in legacy flat map so existing watcher processing still works
|
|
77
78
|
taskMessages.set(taskId, { channelId, threadTs, messageTs });
|
|
78
79
|
|
|
80
|
+
try {
|
|
81
|
+
setSlackMessageTracking(taskId, {
|
|
82
|
+
slackProgressMessageTs: messageTs,
|
|
83
|
+
slackTreeRootMessageTs: messageTs,
|
|
84
|
+
});
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error(`[Slack] Failed to persist message tracking for task ${taskId}:`, error);
|
|
87
|
+
}
|
|
88
|
+
|
|
79
89
|
console.log(`[Slack] Registered task ${taskId.slice(0, 8)} in tree message ${messageTs}`);
|
|
80
90
|
}
|
|
81
91
|
|
|
@@ -193,6 +203,12 @@ export function _getTreeMessages(): Map<string, TreeMessageState> {
|
|
|
193
203
|
export function _getTaskToTree(): Map<string, string> {
|
|
194
204
|
return taskToTree;
|
|
195
205
|
}
|
|
206
|
+
export function _getTaskMessages(): Map<
|
|
207
|
+
string,
|
|
208
|
+
{ channelId: string; threadTs: string; messageTs: string }
|
|
209
|
+
> {
|
|
210
|
+
return taskMessages;
|
|
211
|
+
}
|
|
196
212
|
export function _getLastRenderedTree(): Map<string, string> {
|
|
197
213
|
return lastRenderedTree;
|
|
198
214
|
}
|
|
@@ -306,13 +322,35 @@ export async function processTreeMessages(): Promise<void> {
|
|
|
306
322
|
: `Tasks in progress: ${rootNames}`;
|
|
307
323
|
|
|
308
324
|
// Update the Slack message
|
|
309
|
-
const
|
|
325
|
+
const result = await updateTreeMessage(tree.channelId, messageTs, blocks, fallbackText);
|
|
326
|
+
const success = result === "ok";
|
|
310
327
|
if (success) {
|
|
311
328
|
lastRenderedTree.set(messageTs, serialized);
|
|
312
329
|
treeLastUpdateTime.set(messageTs, now);
|
|
313
330
|
console.log(
|
|
314
331
|
`[Slack] Updated tree message ${messageTs} (${nodes.length} root(s), terminal=${fullyTerminal})`,
|
|
315
332
|
);
|
|
333
|
+
} else if (result === "not_found") {
|
|
334
|
+
const taskIds = Array.from(tree.rootTaskIds);
|
|
335
|
+
for (const taskId of taskIds) {
|
|
336
|
+
taskToTree.delete(taskId);
|
|
337
|
+
taskMessages.delete(taskId);
|
|
338
|
+
try {
|
|
339
|
+
setSlackMessageTracking(taskId, {
|
|
340
|
+
slackProgressMessageTs: null,
|
|
341
|
+
slackTreeRootMessageTs: null,
|
|
342
|
+
});
|
|
343
|
+
} catch (error) {
|
|
344
|
+
console.error(`[Slack] Failed to clear stale message tracking for ${taskId}:`, error);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
treeMessages.delete(messageTs);
|
|
348
|
+
lastRenderedTree.delete(messageTs);
|
|
349
|
+
treeLastUpdateTime.delete(messageTs);
|
|
350
|
+
console.warn(
|
|
351
|
+
`[Slack] Dropped stale tree ${messageTs} (${taskIds.length} task(s)); will repost on next tick`,
|
|
352
|
+
);
|
|
353
|
+
continue;
|
|
316
354
|
}
|
|
317
355
|
|
|
318
356
|
// DM channels: set assistant status in parallel for typing indicator UX
|
|
@@ -437,6 +475,50 @@ export function startTaskWatcher(intervalMs = 3000): void {
|
|
|
437
475
|
}
|
|
438
476
|
console.log(`[Slack] Initialized with ${existingCompleted.length} existing completed tasks`);
|
|
439
477
|
|
|
478
|
+
let hydratedTrees = 0;
|
|
479
|
+
let hydratedFlat = 0;
|
|
480
|
+
for (const task of getInProgressSlackTasks()) {
|
|
481
|
+
if (!task.slackChannelId || !task.slackThreadTs) continue;
|
|
482
|
+
|
|
483
|
+
const treeTs = task.slackTreeRootMessageTs;
|
|
484
|
+
const progressTs = task.slackProgressMessageTs;
|
|
485
|
+
|
|
486
|
+
if (treeTs) {
|
|
487
|
+
let tree = treeMessages.get(treeTs);
|
|
488
|
+
if (!tree) {
|
|
489
|
+
tree = {
|
|
490
|
+
channelId: task.slackChannelId,
|
|
491
|
+
threadTs: task.slackThreadTs,
|
|
492
|
+
messageTs: treeTs,
|
|
493
|
+
rootTaskIds: new Set(),
|
|
494
|
+
};
|
|
495
|
+
treeMessages.set(treeTs, tree);
|
|
496
|
+
}
|
|
497
|
+
tree.rootTaskIds.add(task.id);
|
|
498
|
+
taskToTree.set(task.id, treeTs);
|
|
499
|
+
taskMessages.set(task.id, {
|
|
500
|
+
channelId: task.slackChannelId,
|
|
501
|
+
threadTs: task.slackThreadTs,
|
|
502
|
+
messageTs: treeTs,
|
|
503
|
+
});
|
|
504
|
+
if (task.progress) sentProgress.set(task.id, task.progress);
|
|
505
|
+
hydratedTrees++;
|
|
506
|
+
} else if (progressTs) {
|
|
507
|
+
taskMessages.set(task.id, {
|
|
508
|
+
channelId: task.slackChannelId,
|
|
509
|
+
threadTs: task.slackThreadTs,
|
|
510
|
+
messageTs: progressTs,
|
|
511
|
+
});
|
|
512
|
+
if (task.progress) sentProgress.set(task.id, task.progress);
|
|
513
|
+
hydratedFlat++;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (hydratedTrees > 0 || hydratedFlat > 0) {
|
|
517
|
+
console.log(
|
|
518
|
+
`[Slack] Hydrated ${hydratedTrees} tree task(s) and ${hydratedFlat} flat task(s) from DB`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
440
522
|
watcherInterval = setInterval(async () => {
|
|
441
523
|
// Prevent overlapping processing cycles
|
|
442
524
|
if (isProcessing || !getSlackApp()) return;
|
|
@@ -515,8 +597,20 @@ export function startTaskWatcher(intervalMs = 3000): void {
|
|
|
515
597
|
sentProgress.set(task.id, "__in_progress__");
|
|
516
598
|
lastSendTime.set(progressKey, now);
|
|
517
599
|
try {
|
|
518
|
-
await updateProgressInPlace(task, "Starting...", tracked.messageTs);
|
|
519
|
-
|
|
600
|
+
const result = await updateProgressInPlace(task, "Starting...", tracked.messageTs);
|
|
601
|
+
if (result === "not_found") {
|
|
602
|
+
taskMessages.delete(task.id);
|
|
603
|
+
sentProgress.delete(task.id);
|
|
604
|
+
setSlackMessageTracking(task.id, {
|
|
605
|
+
slackProgressMessageTs: null,
|
|
606
|
+
slackTreeRootMessageTs: null,
|
|
607
|
+
});
|
|
608
|
+
} else if (result === "ok") {
|
|
609
|
+
console.log(`[Slack] Updated to in-progress for task ${task.id.slice(0, 8)}`);
|
|
610
|
+
} else {
|
|
611
|
+
sentProgress.delete(task.id);
|
|
612
|
+
lastSendTime.delete(progressKey);
|
|
613
|
+
}
|
|
520
614
|
} catch (error) {
|
|
521
615
|
sentProgress.delete(task.id);
|
|
522
616
|
lastSendTime.delete(progressKey);
|
|
@@ -535,23 +629,42 @@ export function startTaskWatcher(intervalMs = 3000): void {
|
|
|
535
629
|
sentProgress.set(task.id, task.progress);
|
|
536
630
|
lastSendTime.set(progressKey, now);
|
|
537
631
|
try {
|
|
632
|
+
let postedTs: string | undefined;
|
|
538
633
|
if (tracked) {
|
|
539
634
|
// Update the existing message in-place via chat.update
|
|
540
|
-
await updateProgressInPlace(task, task.progress, tracked.messageTs);
|
|
541
|
-
|
|
635
|
+
const result = await updateProgressInPlace(task, task.progress, tracked.messageTs);
|
|
636
|
+
if (result === "ok") {
|
|
637
|
+
console.log(`[Slack] Updated progress in-place for task ${task.id.slice(0, 8)}`);
|
|
638
|
+
} else if (result === "not_found") {
|
|
639
|
+
taskMessages.delete(task.id);
|
|
640
|
+
postedTs = await sendProgressUpdate(task, task.progress);
|
|
641
|
+
if (postedTs && task.slackChannelId && task.slackThreadTs) {
|
|
642
|
+
taskMessages.set(task.id, {
|
|
643
|
+
channelId: task.slackChannelId,
|
|
644
|
+
threadTs: task.slackThreadTs,
|
|
645
|
+
messageTs: postedTs,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
} else {
|
|
649
|
+
sentProgress.delete(task.id);
|
|
650
|
+
lastSendTime.delete(progressKey);
|
|
651
|
+
}
|
|
542
652
|
} else {
|
|
543
653
|
// No tracked message (e.g., multi-task assignment or server restart)
|
|
544
654
|
// Post a new progress message and track its ts
|
|
545
|
-
|
|
546
|
-
if (
|
|
655
|
+
postedTs = await sendProgressUpdate(task, task.progress);
|
|
656
|
+
if (postedTs && task.slackChannelId && task.slackThreadTs) {
|
|
547
657
|
taskMessages.set(task.id, {
|
|
548
658
|
channelId: task.slackChannelId,
|
|
549
659
|
threadTs: task.slackThreadTs,
|
|
550
|
-
messageTs,
|
|
660
|
+
messageTs: postedTs,
|
|
551
661
|
});
|
|
552
662
|
}
|
|
553
663
|
console.log(`[Slack] Sent initial progress for task ${task.id.slice(0, 8)}`);
|
|
554
664
|
}
|
|
665
|
+
if (postedTs) {
|
|
666
|
+
setSlackMessageTracking(task.id, { slackProgressMessageTs: postedTs });
|
|
667
|
+
}
|
|
555
668
|
} catch (error) {
|
|
556
669
|
// If send fails, clear markers so we can retry
|
|
557
670
|
sentProgress.delete(task.id);
|
|
@@ -15,7 +15,6 @@ describe("createAdditiveBuffer", () => {
|
|
|
15
15
|
/positive number/,
|
|
16
16
|
);
|
|
17
17
|
expect(() => createAdditiveBuffer({ timeoutMs: -1, onFlush: () => {} })).toThrow();
|
|
18
|
-
// biome-ignore lint/suspicious/noExplicitAny: type-guard test
|
|
19
18
|
expect(() => createAdditiveBuffer({ timeoutMs: NaN as any, onFlush: () => {} })).toThrow();
|
|
20
19
|
});
|
|
21
20
|
|
|
@@ -42,4 +42,17 @@ describe("agents list model display", () => {
|
|
|
42
42
|
providerId: "openrouter",
|
|
43
43
|
});
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
test("presents latest Anthropic direct model ids as readable labels", () => {
|
|
47
|
+
expect(getAgentModelPresentation("claude-fable-5")).toMatchObject({
|
|
48
|
+
label: "Claude Fable 5",
|
|
49
|
+
provider: "Anthropic",
|
|
50
|
+
providerId: "anthropic",
|
|
51
|
+
});
|
|
52
|
+
expect(getAgentModelPresentation("claude-mythos-5")).toMatchObject({
|
|
53
|
+
label: "Claude Mythos 5",
|
|
54
|
+
provider: "Anthropic",
|
|
55
|
+
providerId: "anthropic",
|
|
56
|
+
});
|
|
57
|
+
});
|
|
45
58
|
});
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
6
|
import { unlink } from "node:fs/promises";
|
|
7
7
|
import {
|
|
8
|
+
clearKeyRateLimit,
|
|
8
9
|
closeDb,
|
|
9
10
|
getAvailableKeyIndices,
|
|
10
11
|
getKeyStatuses,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
markKeyRateLimited,
|
|
13
14
|
recordKeyUsage,
|
|
14
15
|
} from "../be/db";
|
|
16
|
+
import type { CredentialSelection } from "../utils/credentials";
|
|
15
17
|
import { resolveCredentialPools, selectCredential } from "../utils/credentials";
|
|
16
18
|
|
|
17
19
|
// ─── Credential Selection Unit Tests ────────────────────────────────────────
|
|
@@ -53,6 +55,7 @@ describe("selectCredential", () => {
|
|
|
53
55
|
const value = "key-aaa11,key-bbb22";
|
|
54
56
|
const result = selectCredential(value, []);
|
|
55
57
|
expect(["key-aaa11", "key-bbb22"]).toContain(result.selected);
|
|
58
|
+
expect(result.isRateLimitFallback).toBe(true);
|
|
56
59
|
});
|
|
57
60
|
|
|
58
61
|
test("filters out-of-range availableIndices", () => {
|
|
@@ -60,6 +63,23 @@ describe("selectCredential", () => {
|
|
|
60
63
|
const result = selectCredential(value, [99]); // Out of range
|
|
61
64
|
// Falls back to random
|
|
62
65
|
expect(["key-aaa11", "key-bbb22"]).toContain(result.selected);
|
|
66
|
+
expect(result.isRateLimitFallback).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("isRateLimitFallback is false when indices are available", () => {
|
|
70
|
+
const result = selectCredential("key-aaa11,key-bbb22", [0, 1]);
|
|
71
|
+
expect(result.isRateLimitFallback).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("isRateLimitFallback is false when no availability info", () => {
|
|
75
|
+
const result = selectCredential("key-aaa11,key-bbb22");
|
|
76
|
+
expect(result.isRateLimitFallback).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("single key with empty availableIndices sets isRateLimitFallback", () => {
|
|
80
|
+
const result = selectCredential("single-key", []);
|
|
81
|
+
expect(result.isRateLimitFallback).toBe(true);
|
|
82
|
+
expect(result.selected).toBe("single-key");
|
|
63
83
|
});
|
|
64
84
|
|
|
65
85
|
test("keySuffix is last 5 chars of selected key", () => {
|
|
@@ -198,4 +218,97 @@ describe("API key tracking DB queries", () => {
|
|
|
198
218
|
const key1b = statuses2.find((s) => s.keySuffix === "bbb22");
|
|
199
219
|
expect(key1b!.rateLimitCount).toBe(2);
|
|
200
220
|
});
|
|
221
|
+
|
|
222
|
+
test("clearKeyRateLimit clears a rate-limited key", () => {
|
|
223
|
+
const until = new Date(Date.now() + 300_000).toISOString();
|
|
224
|
+
recordKeyUsage("OPENAI_API_KEY", "oai01", 0, null);
|
|
225
|
+
markKeyRateLimited("OPENAI_API_KEY", "oai01", 0, until);
|
|
226
|
+
|
|
227
|
+
let statuses = getKeyStatuses("OPENAI_API_KEY");
|
|
228
|
+
expect(statuses.find((s) => s.keySuffix === "oai01")!.status).toBe("rate_limited");
|
|
229
|
+
|
|
230
|
+
const cleared = clearKeyRateLimit("OPENAI_API_KEY", "oai01");
|
|
231
|
+
expect(cleared).toBe(true);
|
|
232
|
+
|
|
233
|
+
statuses = getKeyStatuses("OPENAI_API_KEY");
|
|
234
|
+
expect(statuses.find((s) => s.keySuffix === "oai01")!.status).toBe("available");
|
|
235
|
+
expect(statuses.find((s) => s.keySuffix === "oai01")!.rateLimitedUntil).toBeNull();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("clearKeyRateLimit returns false for already-available key", () => {
|
|
239
|
+
recordKeyUsage("OPENAI_API_KEY", "oai02", 1, null);
|
|
240
|
+
const cleared = clearKeyRateLimit("OPENAI_API_KEY", "oai02");
|
|
241
|
+
expect(cleared).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ─── Cross-keyType Failover Logic Tests ──────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
describe("cross-keyType failover", () => {
|
|
248
|
+
test("prefers non-rate-limited credential when both keyTypes available", () => {
|
|
249
|
+
const rateLimited: CredentialSelection = {
|
|
250
|
+
selected: "sk-xxx",
|
|
251
|
+
index: 0,
|
|
252
|
+
total: 1,
|
|
253
|
+
keySuffix: "k-xxx",
|
|
254
|
+
keyType: "OPENAI_API_KEY",
|
|
255
|
+
isRateLimitFallback: true,
|
|
256
|
+
};
|
|
257
|
+
const healthy: CredentialSelection = {
|
|
258
|
+
selected: "oauth-yyy",
|
|
259
|
+
index: 0,
|
|
260
|
+
total: 2,
|
|
261
|
+
keySuffix: "h-yyy",
|
|
262
|
+
keyType: "CODEX_OAUTH",
|
|
263
|
+
isRateLimitFallback: false,
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// Simulate the runner's primary selection logic
|
|
267
|
+
let primarySelection: CredentialSelection | undefined;
|
|
268
|
+
if (rateLimited && healthy) {
|
|
269
|
+
if (rateLimited.isRateLimitFallback && !healthy.isRateLimitFallback) {
|
|
270
|
+
primarySelection = healthy;
|
|
271
|
+
} else {
|
|
272
|
+
primarySelection = rateLimited;
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
primarySelection = rateLimited ?? healthy;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
expect(primarySelection).toBe(healthy);
|
|
279
|
+
expect(primarySelection!.keyType).toBe("CODEX_OAUTH");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("uses first credential when neither is rate-limited", () => {
|
|
283
|
+
const first: CredentialSelection = {
|
|
284
|
+
selected: "sk-aaa",
|
|
285
|
+
index: 0,
|
|
286
|
+
total: 1,
|
|
287
|
+
keySuffix: "k-aaa",
|
|
288
|
+
keyType: "OPENAI_API_KEY",
|
|
289
|
+
isRateLimitFallback: false,
|
|
290
|
+
};
|
|
291
|
+
const second: CredentialSelection = {
|
|
292
|
+
selected: "oauth-bbb",
|
|
293
|
+
index: 0,
|
|
294
|
+
total: 1,
|
|
295
|
+
keySuffix: "h-bbb",
|
|
296
|
+
keyType: "CODEX_OAUTH",
|
|
297
|
+
isRateLimitFallback: false,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
let primarySelection: CredentialSelection | undefined;
|
|
301
|
+
if (first && second) {
|
|
302
|
+
if (first.isRateLimitFallback && !second.isRateLimitFallback) {
|
|
303
|
+
primarySelection = second;
|
|
304
|
+
} else {
|
|
305
|
+
primarySelection = first;
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
primarySelection = first ?? second;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
expect(primarySelection).toBe(first);
|
|
312
|
+
expect(primarySelection!.keyType).toBe("OPENAI_API_KEY");
|
|
313
|
+
});
|
|
201
314
|
});
|