@desplega.ai/agent-swarm 1.83.0 → 1.83.2
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/openapi.json +177 -10
- package/package.json +6 -6
- package/src/artifact-sdk/server.ts +23 -1
- package/src/be/budget-admission.ts +28 -4
- package/src/be/budget-refusal-notify.ts +19 -3
- package/src/be/db-queries/oauth.ts +43 -0
- package/src/be/db.ts +37 -4
- package/src/be/migrations/074_user_budget_scope.sql +85 -0
- package/src/be/schedules/validate.ts +21 -0
- package/src/be/skill-sync.ts +65 -15
- package/src/commands/resume-session.ts +118 -0
- package/src/commands/runner.ts +178 -121
- package/src/http/core.ts +4 -1
- package/src/http/index.ts +16 -0
- package/src/http/integrations.ts +26 -0
- package/src/http/mcp-user.ts +111 -0
- package/src/http/poll.ts +19 -5
- package/src/http/schedules.ts +35 -10
- package/src/http/skills.ts +27 -2
- package/src/http/users.ts +107 -2
- package/src/jira/client.ts +3 -5
- package/src/jira/oauth.ts +1 -0
- package/src/jira/sync.ts +2 -2
- package/src/oauth/ensure-token.ts +1 -0
- package/src/oauth/wrapper.ts +38 -7
- package/src/providers/claude-adapter.ts +7 -2
- package/src/providers/claude-managed-adapter.ts +1 -1
- package/src/providers/codex-adapter.ts +30 -0
- package/src/providers/opencode-adapter.ts +149 -14
- package/src/providers/pi-mono-adapter.ts +41 -1
- package/src/providers/types.ts +1 -1
- package/src/server-user.ts +117 -0
- package/src/tests/artifact-sdk.test.ts +23 -19
- package/src/tests/budget-user-scope.test.ts +376 -0
- package/src/tests/claude-managed-adapter.test.ts +6 -0
- package/src/tests/codex-adapter.test.ts +192 -0
- package/src/tests/codex-rate-limit-parse.test.ts +256 -0
- package/src/tests/db-queries-oauth.test.ts +43 -0
- package/src/tests/ensure-token.test.ts +93 -0
- package/src/tests/error-tracker.test.ts +52 -0
- package/src/tests/fetch-resolved-env.test.ts +33 -20
- package/src/tests/http-api-integration.test.ts +36 -0
- package/src/tests/http-users.test.ts +29 -1
- package/src/tests/mcp-user-route.test.ts +325 -0
- package/src/tests/opencode-adapter.test.ts +75 -0
- package/src/tests/pi-mono-adapter.test.ts +21 -1
- package/src/tests/rate-limit-event.test.ts +69 -6
- package/src/tests/resume-session.test.ts +93 -0
- package/src/tests/runner-skills-refresh.test.ts +200 -0
- package/src/tests/schedule-validation-helper.test.ts +51 -0
- package/src/tests/skill-sync.test.ts +73 -9
- package/src/tests/skills-signature.test.ts +141 -0
- package/src/tests/task-tools-ctx.test.ts +100 -0
- package/src/tests/task-tools-ownership.test.ts +167 -0
- package/src/tests/update-schedule-mcp-tool.test.ts +161 -0
- package/src/tests/user-token-routes.test.ts +221 -0
- package/src/tools/cancel-task.ts +137 -83
- package/src/tools/get-task-details.ts +73 -59
- package/src/tools/get-tasks.ts +134 -126
- package/src/tools/schedules/update-schedule.ts +48 -8
- package/src/tools/send-task.ts +312 -312
- package/src/tools/slack-upload-file.ts +17 -5
- package/src/tools/task-action.ts +464 -367
- package/src/tools/task-tool-ctx.ts +43 -0
- package/src/types.ts +6 -2
- package/src/utils/error-tracker.ts +122 -9
- package/src/utils/skills-refresh.ts +123 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
-- 074_user_budget_scope.sql
|
|
2
|
+
-- Add per-user budget enforcement for client-side MCP users.
|
|
3
|
+
--
|
|
4
|
+
-- SQLite cannot widen a CHECK constraint in place, so recreate the affected
|
|
5
|
+
-- tables and preserve their data. The `budgets` table gains scope='user'.
|
|
6
|
+
-- `budget_refusal_notifications` gains cause='user' and optional user
|
|
7
|
+
-- spend/budget audit columns so claim-time user-budget refusals can share the
|
|
8
|
+
-- existing dedup + lead-notification rail.
|
|
9
|
+
|
|
10
|
+
CREATE TABLE budgets_new (
|
|
11
|
+
scope TEXT NOT NULL,
|
|
12
|
+
scope_id TEXT NOT NULL,
|
|
13
|
+
daily_budget_usd REAL NOT NULL,
|
|
14
|
+
createdAt INTEGER NOT NULL,
|
|
15
|
+
lastUpdatedAt INTEGER NOT NULL,
|
|
16
|
+
PRIMARY KEY (scope, scope_id),
|
|
17
|
+
CHECK (scope IN ('global', 'agent', 'user')),
|
|
18
|
+
CHECK (daily_budget_usd >= 0)
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
INSERT INTO budgets_new (scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt)
|
|
22
|
+
SELECT scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt
|
|
23
|
+
FROM budgets;
|
|
24
|
+
|
|
25
|
+
DROP TABLE budgets;
|
|
26
|
+
ALTER TABLE budgets_new RENAME TO budgets;
|
|
27
|
+
|
|
28
|
+
INSERT OR IGNORE INTO budgets (scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt)
|
|
29
|
+
SELECT
|
|
30
|
+
'user',
|
|
31
|
+
id,
|
|
32
|
+
dailyBudgetUsd,
|
|
33
|
+
CAST(strftime('%s', 'now') AS INTEGER) * 1000,
|
|
34
|
+
CAST(strftime('%s', 'now') AS INTEGER) * 1000
|
|
35
|
+
FROM users
|
|
36
|
+
WHERE dailyBudgetUsd IS NOT NULL;
|
|
37
|
+
|
|
38
|
+
CREATE TABLE budget_refusal_notifications_new (
|
|
39
|
+
task_id TEXT NOT NULL,
|
|
40
|
+
date TEXT NOT NULL,
|
|
41
|
+
agent_id TEXT NOT NULL,
|
|
42
|
+
cause TEXT NOT NULL,
|
|
43
|
+
agent_spend_usd REAL,
|
|
44
|
+
agent_budget_usd REAL,
|
|
45
|
+
global_spend_usd REAL,
|
|
46
|
+
global_budget_usd REAL,
|
|
47
|
+
user_spend_usd REAL,
|
|
48
|
+
user_budget_usd REAL,
|
|
49
|
+
follow_up_task_id TEXT,
|
|
50
|
+
createdAt INTEGER NOT NULL,
|
|
51
|
+
PRIMARY KEY (task_id, date),
|
|
52
|
+
CHECK (cause IN ('agent', 'global', 'user'))
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
INSERT INTO budget_refusal_notifications_new (
|
|
56
|
+
task_id,
|
|
57
|
+
date,
|
|
58
|
+
agent_id,
|
|
59
|
+
cause,
|
|
60
|
+
agent_spend_usd,
|
|
61
|
+
agent_budget_usd,
|
|
62
|
+
global_spend_usd,
|
|
63
|
+
global_budget_usd,
|
|
64
|
+
user_spend_usd,
|
|
65
|
+
user_budget_usd,
|
|
66
|
+
follow_up_task_id,
|
|
67
|
+
createdAt
|
|
68
|
+
)
|
|
69
|
+
SELECT
|
|
70
|
+
task_id,
|
|
71
|
+
date,
|
|
72
|
+
agent_id,
|
|
73
|
+
cause,
|
|
74
|
+
agent_spend_usd,
|
|
75
|
+
agent_budget_usd,
|
|
76
|
+
global_spend_usd,
|
|
77
|
+
global_budget_usd,
|
|
78
|
+
NULL,
|
|
79
|
+
NULL,
|
|
80
|
+
follow_up_task_id,
|
|
81
|
+
createdAt
|
|
82
|
+
FROM budget_refusal_notifications;
|
|
83
|
+
|
|
84
|
+
DROP TABLE budget_refusal_notifications;
|
|
85
|
+
ALTER TABLE budget_refusal_notifications_new RENAME TO budget_refusal_notifications;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type MergedScheduleTiming = { mergedCron: string | null; mergedInterval: number | null };
|
|
2
|
+
export type ScheduleTimingPatch = { cronExpression?: string | null; intervalMs?: number | null };
|
|
3
|
+
|
|
4
|
+
export function mergeScheduleTiming(
|
|
5
|
+
existing: { cronExpression: string | null; intervalMs: number | null },
|
|
6
|
+
patch: ScheduleTimingPatch,
|
|
7
|
+
): MergedScheduleTiming {
|
|
8
|
+
return {
|
|
9
|
+
mergedCron: patch.cronExpression !== undefined ? patch.cronExpression : existing.cronExpression,
|
|
10
|
+
mergedInterval: patch.intervalMs !== undefined ? patch.intervalMs : existing.intervalMs,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type RecurringTimingError = { kind: "both-null" } | null;
|
|
15
|
+
|
|
16
|
+
export function validateRecurringTiming(merged: MergedScheduleTiming): RecurringTimingError {
|
|
17
|
+
if (merged.mergedCron === null && merged.mergedInterval === null) {
|
|
18
|
+
return { kind: "both-null" };
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
package/src/be/skill-sync.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Filesystem sync for skills.
|
|
3
3
|
*
|
|
4
|
-
* Writes installed skills to ~/.claude/skills/<name>/SKILL.md
|
|
5
|
-
* ~/.pi/agent/skills/<name>/SKILL.md
|
|
6
|
-
* natively.
|
|
4
|
+
* Writes installed skills to ~/.claude/skills/<name>/SKILL.md,
|
|
5
|
+
* ~/.pi/agent/skills/<name>/SKILL.md, and ~/.codex/skills/<name>/SKILL.md
|
|
6
|
+
* so Claude Code, Pi, and Codex discover them natively.
|
|
7
7
|
*
|
|
8
8
|
* This runs on the API side — workers call it via POST /api/skills/sync-filesystem.
|
|
9
9
|
*/
|
|
@@ -19,6 +19,16 @@ export interface SkillSyncResult {
|
|
|
19
19
|
errors: string[];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Marker file written into every swarm-managed skill directory. Cleanup
|
|
24
|
+
* only ever removes directories that contain this marker, so unrelated
|
|
25
|
+
* personal skills the user installed via the harness's own tooling (e.g.
|
|
26
|
+
* `codex skills add ...` writing into `~/.codex/skills/<name>/`) are left
|
|
27
|
+
* untouched even when the API server shares a HOME with the worker (local
|
|
28
|
+
* dev). See `~/.codex/skills` blast-radius note in PR #555.
|
|
29
|
+
*/
|
|
30
|
+
const SWARM_MARKER_FILE = ".swarm-managed";
|
|
31
|
+
|
|
22
32
|
/**
|
|
23
33
|
* Sync agent's installed skills to the filesystem.
|
|
24
34
|
*
|
|
@@ -27,7 +37,7 @@ export interface SkillSyncResult {
|
|
|
27
37
|
*/
|
|
28
38
|
export function syncSkillsToFilesystem(
|
|
29
39
|
agentId: string,
|
|
30
|
-
harnessType: "claude" | "pi" | "
|
|
40
|
+
harnessType: "claude" | "pi" | "codex" | "all" = "all",
|
|
31
41
|
homeOverride?: string,
|
|
32
42
|
): SkillSyncResult {
|
|
33
43
|
const skills = getAgentSkills(agentId);
|
|
@@ -37,12 +47,15 @@ export function syncSkillsToFilesystem(
|
|
|
37
47
|
|
|
38
48
|
// Directories to write to
|
|
39
49
|
const skillDirs: string[] = [];
|
|
40
|
-
if (harnessType === "claude" || harnessType === "
|
|
50
|
+
if (harnessType === "claude" || harnessType === "all") {
|
|
41
51
|
skillDirs.push(join(home, ".claude", "skills"));
|
|
42
52
|
}
|
|
43
|
-
if (harnessType === "pi" || harnessType === "
|
|
53
|
+
if (harnessType === "pi" || harnessType === "all") {
|
|
44
54
|
skillDirs.push(join(home, ".pi", "agent", "skills"));
|
|
45
55
|
}
|
|
56
|
+
if (harnessType === "codex" || harnessType === "all") {
|
|
57
|
+
skillDirs.push(join(home, ".codex", "skills"));
|
|
58
|
+
}
|
|
46
59
|
|
|
47
60
|
// Ensure base dirs exist
|
|
48
61
|
for (const dir of skillDirs) {
|
|
@@ -66,10 +79,12 @@ export function syncSkillsToFilesystem(
|
|
|
66
79
|
for (const baseDir of skillDirs) {
|
|
67
80
|
const skillDir = join(baseDir, safeName);
|
|
68
81
|
const skillFile = join(skillDir, "SKILL.md");
|
|
82
|
+
const markerFile = join(skillDir, SWARM_MARKER_FILE);
|
|
69
83
|
|
|
70
84
|
try {
|
|
71
85
|
mkdirSync(skillDir, { recursive: true });
|
|
72
86
|
writeFileSync(skillFile, skill.content, "utf-8");
|
|
87
|
+
writeFileSync(markerFile, "", "utf-8");
|
|
73
88
|
synced++;
|
|
74
89
|
} catch (err) {
|
|
75
90
|
errors.push(
|
|
@@ -79,7 +94,10 @@ export function syncSkillsToFilesystem(
|
|
|
79
94
|
}
|
|
80
95
|
}
|
|
81
96
|
|
|
82
|
-
// Cleanup: remove
|
|
97
|
+
// Cleanup: only remove directories WE previously created (marker file
|
|
98
|
+
// present). Leaves user-installed personal skills alone — important on
|
|
99
|
+
// local dev where ~/.codex/skills holds skills the user installed
|
|
100
|
+
// outside the swarm.
|
|
83
101
|
let removed = 0;
|
|
84
102
|
for (const baseDir of skillDirs) {
|
|
85
103
|
if (!existsSync(baseDir)) continue;
|
|
@@ -87,14 +105,15 @@ export function syncSkillsToFilesystem(
|
|
|
87
105
|
try {
|
|
88
106
|
const existing = readdirSync(baseDir, { withFileTypes: true });
|
|
89
107
|
for (const entry of existing) {
|
|
90
|
-
if (entry.isDirectory()
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
108
|
+
if (!entry.isDirectory()) continue;
|
|
109
|
+
if (writtenNames.has(entry.name)) continue;
|
|
110
|
+
const skillDir = join(baseDir, entry.name);
|
|
111
|
+
if (!existsSync(join(skillDir, SWARM_MARKER_FILE))) continue;
|
|
112
|
+
try {
|
|
113
|
+
rmSync(skillDir, { recursive: true, force: true });
|
|
114
|
+
removed++;
|
|
115
|
+
} catch {
|
|
116
|
+
// Non-fatal — skip cleanup errors
|
|
98
117
|
}
|
|
99
118
|
}
|
|
100
119
|
} catch {
|
|
@@ -104,3 +123,34 @@ export function syncSkillsToFilesystem(
|
|
|
104
123
|
|
|
105
124
|
return { synced, removed, errors };
|
|
106
125
|
}
|
|
126
|
+
|
|
127
|
+
export interface SkillsSignature {
|
|
128
|
+
hash: string;
|
|
129
|
+
count: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Compute a stable signature over an agent's installed-and-enabled skill set.
|
|
134
|
+
*
|
|
135
|
+
* Hash inputs are the per-row mutation-tracking fields — any install,
|
|
136
|
+
* uninstall, toggle, or skill-update mutates at least one of them. Output is
|
|
137
|
+
* deterministic and contains no timestamps beyond per-row mutation fields.
|
|
138
|
+
*/
|
|
139
|
+
export function computeAgentSkillsSignature(agentId: string): SkillsSignature {
|
|
140
|
+
const skills = getAgentSkills(agentId);
|
|
141
|
+
const sorted = [...skills].sort((a, b) => a.id.localeCompare(b.id));
|
|
142
|
+
const canonical = JSON.stringify(
|
|
143
|
+
sorted.map((s) => [
|
|
144
|
+
s.id,
|
|
145
|
+
s.name,
|
|
146
|
+
s.version,
|
|
147
|
+
s.isEnabled,
|
|
148
|
+
s.isActive,
|
|
149
|
+
s.lastUpdatedAt,
|
|
150
|
+
s.sourceHash ?? "",
|
|
151
|
+
s.installedAt,
|
|
152
|
+
]),
|
|
153
|
+
);
|
|
154
|
+
const hash = new Bun.CryptoHasher("sha256").update(canonical).digest("hex");
|
|
155
|
+
return { hash, count: sorted.length };
|
|
156
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { ProviderName } from "../types";
|
|
2
|
+
|
|
3
|
+
export type ResumeSessionSource = "task" | "parent";
|
|
4
|
+
|
|
5
|
+
export interface ResumeSessionCandidate {
|
|
6
|
+
source: ResumeSessionSource;
|
|
7
|
+
sessionId?: string | null;
|
|
8
|
+
taskId?: string;
|
|
9
|
+
provider?: ProviderName;
|
|
10
|
+
providerMeta?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ResumeSessionSkip {
|
|
14
|
+
source: ResumeSessionSource;
|
|
15
|
+
sessionId: string;
|
|
16
|
+
provider?: ProviderName;
|
|
17
|
+
reason: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ResumeSessionResolution {
|
|
21
|
+
resumeSessionId?: string;
|
|
22
|
+
source?: ResumeSessionSource;
|
|
23
|
+
provider?: ProviderName;
|
|
24
|
+
skipped: ResumeSessionSkip[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
28
|
+
|
|
29
|
+
const RESUMABLE_PROVIDERS = new Set<ProviderName>(["claude", "claude-managed", "codex"]);
|
|
30
|
+
|
|
31
|
+
export function isClaudeCliSessionId(sessionId: string): boolean {
|
|
32
|
+
return UUID_RE.test(sessionId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeStoredProvider(candidate: ResumeSessionCandidate): ProviderName | undefined {
|
|
36
|
+
if (candidate.provider === "claude" && candidate.providerMeta?.managed === true) {
|
|
37
|
+
return "claude-managed";
|
|
38
|
+
}
|
|
39
|
+
return candidate.provider;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function providerSupportsResume(provider: ProviderName): boolean {
|
|
43
|
+
return RESUMABLE_PROVIDERS.has(provider);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveResumeSession(
|
|
47
|
+
currentProvider: ProviderName,
|
|
48
|
+
candidates: ResumeSessionCandidate[],
|
|
49
|
+
): ResumeSessionResolution {
|
|
50
|
+
const skipped: ResumeSessionSkip[] = [];
|
|
51
|
+
|
|
52
|
+
for (const candidate of candidates) {
|
|
53
|
+
const sessionId = candidate.sessionId?.trim();
|
|
54
|
+
if (!sessionId) continue;
|
|
55
|
+
|
|
56
|
+
const storedProvider = normalizeStoredProvider(candidate);
|
|
57
|
+
|
|
58
|
+
if (!storedProvider) {
|
|
59
|
+
if (currentProvider === "claude" && isClaudeCliSessionId(sessionId)) {
|
|
60
|
+
return {
|
|
61
|
+
resumeSessionId: sessionId,
|
|
62
|
+
source: candidate.source,
|
|
63
|
+
provider: "claude",
|
|
64
|
+
skipped,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
skipped.push({
|
|
69
|
+
source: candidate.source,
|
|
70
|
+
sessionId,
|
|
71
|
+
reason:
|
|
72
|
+
currentProvider === "claude"
|
|
73
|
+
? "legacy Claude resume requires a UUID session id"
|
|
74
|
+
: "stored session provider is unknown",
|
|
75
|
+
});
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (storedProvider !== currentProvider) {
|
|
80
|
+
skipped.push({
|
|
81
|
+
source: candidate.source,
|
|
82
|
+
sessionId,
|
|
83
|
+
provider: storedProvider,
|
|
84
|
+
reason: `stored session provider ${storedProvider} does not match current provider ${currentProvider}`,
|
|
85
|
+
});
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!providerSupportsResume(currentProvider)) {
|
|
90
|
+
skipped.push({
|
|
91
|
+
source: candidate.source,
|
|
92
|
+
sessionId,
|
|
93
|
+
provider: storedProvider,
|
|
94
|
+
reason: `provider ${currentProvider} does not support runner resume`,
|
|
95
|
+
});
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (currentProvider === "claude" && !isClaudeCliSessionId(sessionId)) {
|
|
100
|
+
skipped.push({
|
|
101
|
+
source: candidate.source,
|
|
102
|
+
sessionId,
|
|
103
|
+
provider: storedProvider,
|
|
104
|
+
reason: "Claude CLI --resume requires a UUID session id",
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
resumeSessionId: sessionId,
|
|
111
|
+
source: candidate.source,
|
|
112
|
+
provider: storedProvider,
|
|
113
|
+
skipped,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { skipped };
|
|
118
|
+
}
|