@agentstep/agent-sdk 0.1.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/package.json +45 -0
- package/src/auth/middleware.ts +38 -0
- package/src/backends/claude/args.ts +88 -0
- package/src/backends/claude/index.ts +193 -0
- package/src/backends/claude/permission-hook.ts +152 -0
- package/src/backends/claude/tool-bridge.ts +211 -0
- package/src/backends/claude/translator.ts +209 -0
- package/src/backends/claude/wrapper-script.ts +45 -0
- package/src/backends/codex/args.ts +69 -0
- package/src/backends/codex/auth.ts +35 -0
- package/src/backends/codex/index.ts +57 -0
- package/src/backends/codex/setup.ts +37 -0
- package/src/backends/codex/translator.ts +223 -0
- package/src/backends/codex/wrapper-script.ts +26 -0
- package/src/backends/factory/args.ts +45 -0
- package/src/backends/factory/auth.ts +30 -0
- package/src/backends/factory/index.ts +56 -0
- package/src/backends/factory/setup.ts +34 -0
- package/src/backends/factory/translator.ts +139 -0
- package/src/backends/factory/wrapper-script.ts +33 -0
- package/src/backends/gemini/args.ts +44 -0
- package/src/backends/gemini/auth.ts +30 -0
- package/src/backends/gemini/index.ts +53 -0
- package/src/backends/gemini/setup.ts +34 -0
- package/src/backends/gemini/translator.ts +139 -0
- package/src/backends/gemini/wrapper-script.ts +26 -0
- package/src/backends/opencode/args.ts +53 -0
- package/src/backends/opencode/auth.ts +53 -0
- package/src/backends/opencode/index.ts +70 -0
- package/src/backends/opencode/mcp.ts +67 -0
- package/src/backends/opencode/setup.ts +54 -0
- package/src/backends/opencode/translator.ts +168 -0
- package/src/backends/opencode/wrapper-script.ts +46 -0
- package/src/backends/registry.ts +38 -0
- package/src/backends/shared/ndjson.ts +29 -0
- package/src/backends/shared/translator-types.ts +69 -0
- package/src/backends/shared/wrap-prompt.ts +17 -0
- package/src/backends/types.ts +85 -0
- package/src/config/index.ts +95 -0
- package/src/db/agents.ts +185 -0
- package/src/db/api_keys.ts +78 -0
- package/src/db/batch.ts +142 -0
- package/src/db/client.ts +81 -0
- package/src/db/environments.ts +127 -0
- package/src/db/events.ts +208 -0
- package/src/db/memory.ts +143 -0
- package/src/db/migrations.ts +295 -0
- package/src/db/proxy.ts +37 -0
- package/src/db/sessions.ts +295 -0
- package/src/db/vaults.ts +110 -0
- package/src/errors.ts +53 -0
- package/src/handlers/agents.ts +194 -0
- package/src/handlers/batch.ts +41 -0
- package/src/handlers/docs.ts +87 -0
- package/src/handlers/environments.ts +154 -0
- package/src/handlers/events.ts +234 -0
- package/src/handlers/index.ts +12 -0
- package/src/handlers/memory.ts +141 -0
- package/src/handlers/openapi.ts +14 -0
- package/src/handlers/sessions.ts +223 -0
- package/src/handlers/stream.ts +76 -0
- package/src/handlers/threads.ts +26 -0
- package/src/handlers/ui/app.js +984 -0
- package/src/handlers/ui/index.html +112 -0
- package/src/handlers/ui/style.css +164 -0
- package/src/handlers/ui.ts +1281 -0
- package/src/handlers/vaults.ts +99 -0
- package/src/http.ts +35 -0
- package/src/index.ts +104 -0
- package/src/init.ts +227 -0
- package/src/openapi/registry.ts +8 -0
- package/src/openapi/schemas.ts +625 -0
- package/src/openapi/spec.ts +691 -0
- package/src/providers/apple.ts +220 -0
- package/src/providers/daytona.ts +217 -0
- package/src/providers/docker.ts +264 -0
- package/src/providers/e2b.ts +203 -0
- package/src/providers/fly.ts +276 -0
- package/src/providers/modal.ts +222 -0
- package/src/providers/podman.ts +206 -0
- package/src/providers/registry.ts +28 -0
- package/src/providers/shared.ts +11 -0
- package/src/providers/sprites.ts +55 -0
- package/src/providers/types.ts +73 -0
- package/src/providers/vercel.ts +208 -0
- package/src/proxy/forward.ts +111 -0
- package/src/queue/index.ts +111 -0
- package/src/sessions/actor.ts +53 -0
- package/src/sessions/bus.ts +155 -0
- package/src/sessions/driver.ts +818 -0
- package/src/sessions/grader.ts +120 -0
- package/src/sessions/interrupt.ts +14 -0
- package/src/sessions/sweeper.ts +136 -0
- package/src/sessions/threads.ts +126 -0
- package/src/sessions/tools.ts +50 -0
- package/src/shutdown.ts +78 -0
- package/src/sprite/client.ts +294 -0
- package/src/sprite/exec.ts +161 -0
- package/src/sprite/lifecycle.ts +339 -0
- package/src/sprite/pool.ts +65 -0
- package/src/sprite/setup.ts +159 -0
- package/src/state.ts +61 -0
- package/src/types.ts +339 -0
- package/src/util/clock.ts +7 -0
- package/src/util/ids.ts +11 -0
package/src/db/agents.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { getDb } from "./client";
|
|
2
|
+
import { newId } from "../util/ids";
|
|
3
|
+
import { nowMs, toIso } from "../util/clock";
|
|
4
|
+
import type {
|
|
5
|
+
Agent,
|
|
6
|
+
AgentRow,
|
|
7
|
+
AgentVersionRow,
|
|
8
|
+
BackendName,
|
|
9
|
+
McpServerConfig,
|
|
10
|
+
ToolConfig,
|
|
11
|
+
} from "../types";
|
|
12
|
+
|
|
13
|
+
function hydrate(row: AgentRow, ver: AgentVersionRow): Agent {
|
|
14
|
+
return {
|
|
15
|
+
id: row.id,
|
|
16
|
+
version: ver.version,
|
|
17
|
+
name: row.name,
|
|
18
|
+
model: ver.model,
|
|
19
|
+
system: ver.system,
|
|
20
|
+
tools: JSON.parse(ver.tools_json) as ToolConfig[],
|
|
21
|
+
mcp_servers: JSON.parse(ver.mcp_servers_json) as Record<string, McpServerConfig>,
|
|
22
|
+
backend: (ver.backend ?? "claude") as BackendName,
|
|
23
|
+
webhook_url: ver.webhook_url ?? null,
|
|
24
|
+
webhook_events: ver.webhook_events_json ? (JSON.parse(ver.webhook_events_json) as string[]) : ["session.status_idle", "session.status_running", "session.error"],
|
|
25
|
+
threads_enabled: Boolean(ver.threads_enabled),
|
|
26
|
+
confirmation_mode: Boolean(ver.confirmation_mode),
|
|
27
|
+
callable_agents: ver.callable_agents_json ? JSON.parse(ver.callable_agents_json) : [],
|
|
28
|
+
created_at: toIso(row.created_at),
|
|
29
|
+
updated_at: toIso(row.updated_at),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createAgent(input: {
|
|
34
|
+
name: string;
|
|
35
|
+
model: string;
|
|
36
|
+
system?: string | null;
|
|
37
|
+
tools?: ToolConfig[];
|
|
38
|
+
mcp_servers?: Record<string, McpServerConfig>;
|
|
39
|
+
backend?: BackendName;
|
|
40
|
+
webhook_url?: string | null;
|
|
41
|
+
webhook_events?: string[];
|
|
42
|
+
threads_enabled?: boolean;
|
|
43
|
+
confirmation_mode?: boolean;
|
|
44
|
+
callable_agents?: Array<{ type: "agent"; id: string; version?: number }>;
|
|
45
|
+
}): Agent {
|
|
46
|
+
const db = getDb();
|
|
47
|
+
const id = newId("agent");
|
|
48
|
+
const now = nowMs();
|
|
49
|
+
|
|
50
|
+
const tx = db.transaction(() => {
|
|
51
|
+
db.prepare(
|
|
52
|
+
`INSERT INTO agents (id, current_version, name, created_at, updated_at)
|
|
53
|
+
VALUES (?, 1, ?, ?, ?)`,
|
|
54
|
+
).run(id, input.name, now, now);
|
|
55
|
+
|
|
56
|
+
db.prepare(
|
|
57
|
+
`INSERT INTO agent_versions
|
|
58
|
+
(agent_id, version, model, system, tools_json, mcp_servers_json, backend, webhook_url, webhook_events_json, threads_enabled, confirmation_mode, callable_agents_json, created_at)
|
|
59
|
+
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
60
|
+
).run(
|
|
61
|
+
id,
|
|
62
|
+
input.model,
|
|
63
|
+
input.system ?? null,
|
|
64
|
+
JSON.stringify(input.tools ?? []),
|
|
65
|
+
JSON.stringify(input.mcp_servers ?? {}),
|
|
66
|
+
input.backend ?? "claude",
|
|
67
|
+
input.webhook_url ?? null,
|
|
68
|
+
JSON.stringify(input.webhook_events ?? ["session.status_idle", "session.status_running", "session.error"]),
|
|
69
|
+
input.threads_enabled ? 1 : 0,
|
|
70
|
+
input.confirmation_mode ? 1 : 0,
|
|
71
|
+
input.callable_agents?.length ? JSON.stringify(input.callable_agents) : null,
|
|
72
|
+
now,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
tx();
|
|
76
|
+
|
|
77
|
+
return getAgent(id)!;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getAgent(id: string, version?: number): Agent | null {
|
|
81
|
+
const db = getDb();
|
|
82
|
+
const row = db
|
|
83
|
+
.prepare(`SELECT * FROM agents WHERE id = ?`)
|
|
84
|
+
.get(id) as AgentRow | undefined;
|
|
85
|
+
if (!row) return null;
|
|
86
|
+
|
|
87
|
+
const v = version ?? row.current_version;
|
|
88
|
+
const ver = db
|
|
89
|
+
.prepare(
|
|
90
|
+
`SELECT * FROM agent_versions WHERE agent_id = ? AND version = ?`,
|
|
91
|
+
)
|
|
92
|
+
.get(id, v) as AgentVersionRow | undefined;
|
|
93
|
+
if (!ver) return null;
|
|
94
|
+
|
|
95
|
+
return hydrate(row, ver);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function updateAgent(
|
|
99
|
+
id: string,
|
|
100
|
+
input: {
|
|
101
|
+
name?: string;
|
|
102
|
+
model?: string;
|
|
103
|
+
system?: string | null;
|
|
104
|
+
tools?: ToolConfig[];
|
|
105
|
+
mcp_servers?: Record<string, McpServerConfig>;
|
|
106
|
+
webhook_url?: string | null;
|
|
107
|
+
webhook_events?: string[];
|
|
108
|
+
threads_enabled?: boolean;
|
|
109
|
+
confirmation_mode?: boolean;
|
|
110
|
+
callable_agents?: Array<{ type: "agent"; id: string; version?: number }>;
|
|
111
|
+
},
|
|
112
|
+
): Agent | null {
|
|
113
|
+
const db = getDb();
|
|
114
|
+
const existing = getAgent(id);
|
|
115
|
+
if (!existing) return null;
|
|
116
|
+
|
|
117
|
+
const newVersion = existing.version + 1;
|
|
118
|
+
const now = nowMs();
|
|
119
|
+
|
|
120
|
+
const tx = db.transaction(() => {
|
|
121
|
+
db.prepare(
|
|
122
|
+
`INSERT INTO agent_versions
|
|
123
|
+
(agent_id, version, model, system, tools_json, mcp_servers_json, backend, webhook_url, webhook_events_json, threads_enabled, confirmation_mode, callable_agents_json, created_at)
|
|
124
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
125
|
+
).run(
|
|
126
|
+
id,
|
|
127
|
+
newVersion,
|
|
128
|
+
input.model ?? existing.model,
|
|
129
|
+
input.system ?? existing.system,
|
|
130
|
+
JSON.stringify(input.tools ?? existing.tools),
|
|
131
|
+
JSON.stringify(input.mcp_servers ?? existing.mcp_servers),
|
|
132
|
+
existing.backend,
|
|
133
|
+
input.webhook_url !== undefined ? input.webhook_url : existing.webhook_url,
|
|
134
|
+
JSON.stringify(input.webhook_events ?? existing.webhook_events),
|
|
135
|
+
input.threads_enabled !== undefined ? (input.threads_enabled ? 1 : 0) : (existing.threads_enabled ? 1 : 0),
|
|
136
|
+
input.confirmation_mode !== undefined ? (input.confirmation_mode ? 1 : 0) : (existing.confirmation_mode ? 1 : 0),
|
|
137
|
+
input.callable_agents !== undefined ? (input.callable_agents.length ? JSON.stringify(input.callable_agents) : null) : (existing.callable_agents.length ? JSON.stringify(existing.callable_agents) : null),
|
|
138
|
+
now,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
db.prepare(
|
|
142
|
+
`UPDATE agents SET current_version = ?, name = ?, updated_at = ? WHERE id = ?`,
|
|
143
|
+
).run(newVersion, input.name ?? existing.name, now, id);
|
|
144
|
+
});
|
|
145
|
+
tx();
|
|
146
|
+
|
|
147
|
+
return getAgent(id);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function archiveAgent(id: string): boolean {
|
|
151
|
+
const db = getDb();
|
|
152
|
+
const res = db
|
|
153
|
+
.prepare(`UPDATE agents SET archived_at = ? WHERE id = ? AND archived_at IS NULL`)
|
|
154
|
+
.run(nowMs(), id);
|
|
155
|
+
return res.changes > 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function listAgents(opts: {
|
|
159
|
+
limit?: number;
|
|
160
|
+
order?: "asc" | "desc";
|
|
161
|
+
includeArchived?: boolean;
|
|
162
|
+
cursor?: string; // agent id cursor
|
|
163
|
+
}): Agent[] {
|
|
164
|
+
const db = getDb();
|
|
165
|
+
const limit = Math.min(Math.max(opts.limit ?? 20, 1), 100);
|
|
166
|
+
const order = opts.order === "asc" ? "ASC" : "DESC";
|
|
167
|
+
const includeArchived = opts.includeArchived ?? false;
|
|
168
|
+
|
|
169
|
+
const clauses: string[] = [];
|
|
170
|
+
const params: unknown[] = [];
|
|
171
|
+
if (!includeArchived) clauses.push("archived_at IS NULL");
|
|
172
|
+
if (opts.cursor) {
|
|
173
|
+
clauses.push(order === "DESC" ? "id < ?" : "id > ?");
|
|
174
|
+
params.push(opts.cursor);
|
|
175
|
+
}
|
|
176
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
177
|
+
|
|
178
|
+
const rows = db
|
|
179
|
+
.prepare(
|
|
180
|
+
`SELECT * FROM agents ${where} ORDER BY id ${order} LIMIT ?`,
|
|
181
|
+
)
|
|
182
|
+
.all(...params, limit) as AgentRow[];
|
|
183
|
+
|
|
184
|
+
return rows.map((r) => getAgent(r.id)!).filter(Boolean);
|
|
185
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { getDb } from "./client";
|
|
3
|
+
import { newId } from "../util/ids";
|
|
4
|
+
import { nowMs } from "../util/clock";
|
|
5
|
+
|
|
6
|
+
export interface ApiKeyRow {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
hash: string;
|
|
10
|
+
prefix: string;
|
|
11
|
+
permissions_json: string;
|
|
12
|
+
created_at: number;
|
|
13
|
+
revoked_at: number | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hashKey(raw: string): string {
|
|
17
|
+
return crypto.createHash("sha256").update(raw).digest("hex");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create a new API key. Returns the full raw key string ONCE —
|
|
22
|
+
* it is not stored in plain text and cannot be retrieved later.
|
|
23
|
+
*/
|
|
24
|
+
export function createApiKey(input: {
|
|
25
|
+
name: string;
|
|
26
|
+
permissions?: string[];
|
|
27
|
+
rawKey?: string;
|
|
28
|
+
}): { key: string; id: string } {
|
|
29
|
+
const db = getDb();
|
|
30
|
+
const id = newId("key");
|
|
31
|
+
const raw = input.rawKey || `ck_${crypto.randomBytes(24).toString("base64url")}`;
|
|
32
|
+
const hash = hashKey(raw);
|
|
33
|
+
const prefix = raw.slice(0, 8);
|
|
34
|
+
|
|
35
|
+
db.prepare(
|
|
36
|
+
`INSERT INTO api_keys (id, name, hash, prefix, permissions_json, created_at)
|
|
37
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
38
|
+
).run(
|
|
39
|
+
id,
|
|
40
|
+
input.name,
|
|
41
|
+
hash,
|
|
42
|
+
prefix,
|
|
43
|
+
JSON.stringify(input.permissions ?? ["*"]),
|
|
44
|
+
nowMs(),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return { key: raw, id };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function findByRawKey(raw: string): ApiKeyRow | null {
|
|
51
|
+
const db = getDb();
|
|
52
|
+
const hash = hashKey(raw);
|
|
53
|
+
return (
|
|
54
|
+
(db
|
|
55
|
+
.prepare(
|
|
56
|
+
`SELECT * FROM api_keys WHERE hash = ? AND revoked_at IS NULL`,
|
|
57
|
+
)
|
|
58
|
+
.get(hash) as ApiKeyRow | undefined) ?? null
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function revokeApiKey(id: string): boolean {
|
|
63
|
+
const db = getDb();
|
|
64
|
+
const res = db
|
|
65
|
+
.prepare(`UPDATE api_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`)
|
|
66
|
+
.run(nowMs(), id);
|
|
67
|
+
return res.changes > 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function listApiKeys(): Array<Omit<ApiKeyRow, "hash">> {
|
|
71
|
+
const db = getDb();
|
|
72
|
+
const rows = db
|
|
73
|
+
.prepare(
|
|
74
|
+
`SELECT * FROM api_keys WHERE revoked_at IS NULL ORDER BY created_at DESC`,
|
|
75
|
+
)
|
|
76
|
+
.all() as ApiKeyRow[];
|
|
77
|
+
return rows.map(({ hash: _hash, ...rest }) => rest);
|
|
78
|
+
}
|
package/src/db/batch.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch operations: execute multiple resource mutations in a single
|
|
3
|
+
* SQLite transaction. All operations succeed or all fail.
|
|
4
|
+
*/
|
|
5
|
+
import { getDb } from "./client";
|
|
6
|
+
import { createAgent, archiveAgent } from "./agents";
|
|
7
|
+
import { createEnvironment, deleteEnvironment } from "./environments";
|
|
8
|
+
import { createSession } from "./sessions";
|
|
9
|
+
import { getAgent } from "./agents";
|
|
10
|
+
import { getEnvironment } from "./environments";
|
|
11
|
+
import type { EnvironmentConfig } from "../types";
|
|
12
|
+
|
|
13
|
+
export interface BatchOp {
|
|
14
|
+
method: string;
|
|
15
|
+
path: string;
|
|
16
|
+
body?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BatchResult {
|
|
20
|
+
status: number;
|
|
21
|
+
body: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Execute a batch of operations in a single transaction.
|
|
26
|
+
* On any error, the transaction rolls back and the error is returned
|
|
27
|
+
* for the failed operation.
|
|
28
|
+
*/
|
|
29
|
+
export function executeBatch(operations: BatchOp[]): BatchResult[] {
|
|
30
|
+
const db = getDb();
|
|
31
|
+
|
|
32
|
+
return db.transaction(() => {
|
|
33
|
+
const results: BatchResult[] = [];
|
|
34
|
+
|
|
35
|
+
for (const op of operations) {
|
|
36
|
+
try {
|
|
37
|
+
const result = executeSingleOp(op);
|
|
38
|
+
results.push(result);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
41
|
+
// Re-throw to trigger transaction rollback
|
|
42
|
+
throw new BatchError(results.length, msg, results);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return results;
|
|
47
|
+
})();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class BatchError extends Error {
|
|
51
|
+
constructor(
|
|
52
|
+
public readonly failedIndex: number,
|
|
53
|
+
message: string,
|
|
54
|
+
public readonly partialResults: BatchResult[],
|
|
55
|
+
) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = "BatchError";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function executeSingleOp(op: BatchOp): BatchResult {
|
|
62
|
+
const { method, path, body } = op;
|
|
63
|
+
const upperMethod = method.toUpperCase();
|
|
64
|
+
|
|
65
|
+
// POST /v1/agents
|
|
66
|
+
if (upperMethod === "POST" && path === "/v1/agents") {
|
|
67
|
+
if (!body?.name || !body?.model) {
|
|
68
|
+
throw new Error("agent creation requires name and model");
|
|
69
|
+
}
|
|
70
|
+
const agent = createAgent({
|
|
71
|
+
name: body.name as string,
|
|
72
|
+
model: body.model as string,
|
|
73
|
+
system: (body.system as string) ?? null,
|
|
74
|
+
tools: (body.tools as []) ?? [],
|
|
75
|
+
mcp_servers: (body.mcp_servers as Record<string, never>) ?? {},
|
|
76
|
+
backend: (body.backend as "claude") ?? "claude",
|
|
77
|
+
webhook_url: (body.webhook_url as string) ?? null,
|
|
78
|
+
webhook_events: body.webhook_events as string[] | undefined,
|
|
79
|
+
threads_enabled: (body.threads_enabled as boolean) ?? false,
|
|
80
|
+
});
|
|
81
|
+
return { status: 201, body: agent };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// POST /v1/environments
|
|
85
|
+
if (upperMethod === "POST" && path === "/v1/environments") {
|
|
86
|
+
if (!body?.name || !body?.config) {
|
|
87
|
+
throw new Error("environment creation requires name and config");
|
|
88
|
+
}
|
|
89
|
+
const env = createEnvironment({
|
|
90
|
+
name: body.name as string,
|
|
91
|
+
config: body.config as EnvironmentConfig,
|
|
92
|
+
});
|
|
93
|
+
return { status: 201, body: env };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// POST /v1/sessions
|
|
97
|
+
if (upperMethod === "POST" && path === "/v1/sessions") {
|
|
98
|
+
if (!body?.agent || !body?.environment_id) {
|
|
99
|
+
throw new Error("session creation requires agent and environment_id");
|
|
100
|
+
}
|
|
101
|
+
const agentRef = body.agent as string | { id: string; version: number };
|
|
102
|
+
const agentId = typeof agentRef === "string" ? agentRef : agentRef.id;
|
|
103
|
+
const agentVersion = typeof agentRef === "string" ? undefined : agentRef.version;
|
|
104
|
+
|
|
105
|
+
const agent = getAgent(agentId, agentVersion);
|
|
106
|
+
if (!agent) throw new Error(`agent not found: ${agentId}`);
|
|
107
|
+
|
|
108
|
+
const env = getEnvironment(body.environment_id as string);
|
|
109
|
+
if (!env) throw new Error(`environment not found: ${body.environment_id}`);
|
|
110
|
+
|
|
111
|
+
const session = createSession({
|
|
112
|
+
agent_id: agent.id,
|
|
113
|
+
agent_version: agent.version,
|
|
114
|
+
environment_id: env.id,
|
|
115
|
+
title: (body.title as string) ?? null,
|
|
116
|
+
metadata: (body.metadata as Record<string, unknown>) ?? {},
|
|
117
|
+
max_budget_usd: (body.max_budget_usd as number) ?? null,
|
|
118
|
+
vault_ids: (body.vault_ids as string[]) ?? null,
|
|
119
|
+
});
|
|
120
|
+
return { status: 201, body: session };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// DELETE /v1/agents/{id}
|
|
124
|
+
const agentDeleteMatch = path.match(/^\/v1\/agents\/([^/]+)$/);
|
|
125
|
+
if (upperMethod === "DELETE" && agentDeleteMatch) {
|
|
126
|
+
const id = agentDeleteMatch[1];
|
|
127
|
+
const archived = archiveAgent(id);
|
|
128
|
+
if (!archived) throw new Error(`agent not found: ${id}`);
|
|
129
|
+
return { status: 200, body: { id, type: "agent_deleted" } };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// DELETE /v1/environments/{id}
|
|
133
|
+
const envDeleteMatch = path.match(/^\/v1\/environments\/([^/]+)$/);
|
|
134
|
+
if (upperMethod === "DELETE" && envDeleteMatch) {
|
|
135
|
+
const id = envDeleteMatch[1];
|
|
136
|
+
const deleted = deleteEnvironment(id);
|
|
137
|
+
if (!deleted) throw new Error(`environment not found: ${id}`);
|
|
138
|
+
return { status: 200, body: { id, type: "environment_deleted" } };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
throw new Error(`unsupported batch operation: ${method} ${path}`);
|
|
142
|
+
}
|
package/src/db/client.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* libsql client with WAL + synchronous=NORMAL.
|
|
3
|
+
*
|
|
4
|
+
* Supports two modes:
|
|
5
|
+
* 1. Local-only (default): DATABASE_PATH=./data/managed-agents.db
|
|
6
|
+
* 2. Turso embedded replica: TURSO_URL + TURSO_AUTH_TOKEN env vars
|
|
7
|
+
* → local file syncs to/from a remote Turso database
|
|
8
|
+
*
|
|
9
|
+
* HMR-safe singleton: stores the Database instance on globalThis so Next.js
|
|
10
|
+
* dev server reloads don't create a new handle per route compile.
|
|
11
|
+
*/
|
|
12
|
+
import Database from "libsql";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import { runMigrations } from "./migrations";
|
|
16
|
+
|
|
17
|
+
type DB = InstanceType<typeof Database>;
|
|
18
|
+
|
|
19
|
+
type GlobalDB = typeof globalThis & {
|
|
20
|
+
__caDb?: DB;
|
|
21
|
+
__caDbPath?: string;
|
|
22
|
+
};
|
|
23
|
+
const g = globalThis as GlobalDB;
|
|
24
|
+
|
|
25
|
+
function resolveDbPath(): string {
|
|
26
|
+
const p = process.env.DATABASE_PATH || "./data/managed-agents.db";
|
|
27
|
+
// turbopackIgnore: runtime-only path resolution — do not trace at build time
|
|
28
|
+
return path.isAbsolute(p) ? p : path.join(/* turbopackIgnore: true */ process.cwd(), p);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getDb(): DB {
|
|
32
|
+
if (g.__caDb) return g.__caDb;
|
|
33
|
+
|
|
34
|
+
const dbPath = resolveDbPath();
|
|
35
|
+
const dir = path.dirname(dbPath);
|
|
36
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
|
|
38
|
+
const tursoUrl = process.env.TURSO_URL;
|
|
39
|
+
const tursoToken = process.env.TURSO_AUTH_TOKEN;
|
|
40
|
+
|
|
41
|
+
const db = tursoUrl
|
|
42
|
+
? new Database(dbPath, { syncUrl: tursoUrl, authToken: tursoToken } as Record<string, unknown>)
|
|
43
|
+
: new Database(dbPath);
|
|
44
|
+
|
|
45
|
+
db.pragma("journal_mode = WAL");
|
|
46
|
+
db.pragma("synchronous = NORMAL");
|
|
47
|
+
db.pragma("foreign_keys = ON");
|
|
48
|
+
db.pragma("busy_timeout = 5000");
|
|
49
|
+
|
|
50
|
+
runMigrations(db);
|
|
51
|
+
|
|
52
|
+
// Initial sync for embedded replicas
|
|
53
|
+
if (tursoUrl) {
|
|
54
|
+
try {
|
|
55
|
+
(db as unknown as { sync(): void }).sync();
|
|
56
|
+
} catch {
|
|
57
|
+
// sync may fail on first boot if remote is empty — that's fine
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
g.__caDb = db;
|
|
62
|
+
g.__caDbPath = dbPath;
|
|
63
|
+
return db;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Sync embedded replica with remote Turso. No-op if not using Turso. */
|
|
67
|
+
export function syncDb(): void {
|
|
68
|
+
if (!g.__caDb || !process.env.TURSO_URL) return;
|
|
69
|
+
try {
|
|
70
|
+
(g.__caDb as unknown as { sync(): void }).sync();
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.warn("[db] sync failed:", err);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function closeDb(): void {
|
|
77
|
+
if (g.__caDb) {
|
|
78
|
+
g.__caDb.close();
|
|
79
|
+
g.__caDb = undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { getDb } from "./client";
|
|
2
|
+
import { newId } from "../util/ids";
|
|
3
|
+
import { nowMs, toIso } from "../util/clock";
|
|
4
|
+
import type { Environment, EnvironmentConfig, EnvironmentRow, EnvironmentState } from "../types";
|
|
5
|
+
|
|
6
|
+
function hydrate(row: EnvironmentRow): Environment {
|
|
7
|
+
return {
|
|
8
|
+
id: row.id,
|
|
9
|
+
name: row.name,
|
|
10
|
+
config: JSON.parse(row.config_json) as EnvironmentConfig,
|
|
11
|
+
state: row.state,
|
|
12
|
+
state_message: row.state_message,
|
|
13
|
+
created_at: toIso(row.created_at),
|
|
14
|
+
archived_at: row.archived_at ? toIso(row.archived_at) : null,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createEnvironment(input: {
|
|
19
|
+
name: string;
|
|
20
|
+
config: EnvironmentConfig;
|
|
21
|
+
}): Environment {
|
|
22
|
+
const db = getDb();
|
|
23
|
+
const id = newId("env");
|
|
24
|
+
const now = nowMs();
|
|
25
|
+
|
|
26
|
+
db.prepare(
|
|
27
|
+
`INSERT INTO environments (id, name, config_json, state, created_at)
|
|
28
|
+
VALUES (?, ?, ?, 'preparing', ?)`,
|
|
29
|
+
).run(id, input.name, JSON.stringify(input.config), now);
|
|
30
|
+
|
|
31
|
+
return getEnvironment(id)!;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getEnvironment(id: string): Environment | null {
|
|
35
|
+
const db = getDb();
|
|
36
|
+
const row = db
|
|
37
|
+
.prepare(`SELECT * FROM environments WHERE id = ?`)
|
|
38
|
+
.get(id) as EnvironmentRow | undefined;
|
|
39
|
+
return row ? hydrate(row) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getEnvironmentRow(id: string): EnvironmentRow | null {
|
|
43
|
+
const db = getDb();
|
|
44
|
+
return (
|
|
45
|
+
(db
|
|
46
|
+
.prepare(`SELECT * FROM environments WHERE id = ?`)
|
|
47
|
+
.get(id) as EnvironmentRow | undefined) ?? null
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function updateEnvironmentState(
|
|
52
|
+
id: string,
|
|
53
|
+
state: EnvironmentState,
|
|
54
|
+
message?: string | null,
|
|
55
|
+
): void {
|
|
56
|
+
const db = getDb();
|
|
57
|
+
db.prepare(
|
|
58
|
+
`UPDATE environments SET state = ?, state_message = ? WHERE id = ?`,
|
|
59
|
+
).run(state, message ?? null, id);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function updateEnvironmentCheckpoint(
|
|
63
|
+
id: string,
|
|
64
|
+
checkpointId: string,
|
|
65
|
+
templateSprite: string | null,
|
|
66
|
+
): void {
|
|
67
|
+
const db = getDb();
|
|
68
|
+
db.prepare(
|
|
69
|
+
`UPDATE environments SET checkpoint_id = ?, template_sprite = ? WHERE id = ?`,
|
|
70
|
+
).run(checkpointId, templateSprite, id);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function archiveEnvironment(id: string): boolean {
|
|
74
|
+
const db = getDb();
|
|
75
|
+
const res = db
|
|
76
|
+
.prepare(`UPDATE environments SET archived_at = ? WHERE id = ? AND archived_at IS NULL`)
|
|
77
|
+
.run(nowMs(), id);
|
|
78
|
+
return res.changes > 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function deleteEnvironment(id: string): boolean {
|
|
82
|
+
const db = getDb();
|
|
83
|
+
// Archive any sessions referencing this environment to avoid FK constraint
|
|
84
|
+
db.prepare(`UPDATE sessions SET archived_at = ? WHERE environment_id = ? AND archived_at IS NULL`).run(nowMs(), id);
|
|
85
|
+
const res = db.prepare(`DELETE FROM environments WHERE id = ?`).run(id);
|
|
86
|
+
return res.changes > 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function listEnvironments(opts: {
|
|
90
|
+
limit?: number;
|
|
91
|
+
order?: "asc" | "desc";
|
|
92
|
+
includeArchived?: boolean;
|
|
93
|
+
cursor?: string;
|
|
94
|
+
}): Environment[] {
|
|
95
|
+
const db = getDb();
|
|
96
|
+
const limit = Math.min(Math.max(opts.limit ?? 20, 1), 100);
|
|
97
|
+
const order = opts.order === "asc" ? "ASC" : "DESC";
|
|
98
|
+
const includeArchived = opts.includeArchived ?? false;
|
|
99
|
+
|
|
100
|
+
const clauses: string[] = [];
|
|
101
|
+
const params: unknown[] = [];
|
|
102
|
+
if (!includeArchived) clauses.push("archived_at IS NULL");
|
|
103
|
+
if (opts.cursor) {
|
|
104
|
+
clauses.push(order === "DESC" ? "id < ?" : "id > ?");
|
|
105
|
+
params.push(opts.cursor);
|
|
106
|
+
}
|
|
107
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
108
|
+
|
|
109
|
+
const rows = db
|
|
110
|
+
.prepare(
|
|
111
|
+
`SELECT * FROM environments ${where} ORDER BY id ${order} LIMIT ?`,
|
|
112
|
+
)
|
|
113
|
+
.all(...params, limit) as EnvironmentRow[];
|
|
114
|
+
|
|
115
|
+
return rows.map(hydrate);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function hasSessionsAttached(envId: string): boolean {
|
|
119
|
+
const db = getDb();
|
|
120
|
+
const row = db
|
|
121
|
+
.prepare(
|
|
122
|
+
`SELECT COUNT(*) AS n FROM sessions
|
|
123
|
+
WHERE environment_id = ? AND archived_at IS NULL AND status != 'terminated'`,
|
|
124
|
+
)
|
|
125
|
+
.get(envId) as { n: number } | undefined;
|
|
126
|
+
return (row?.n ?? 0) > 0;
|
|
127
|
+
}
|