workplane 0.1.1
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/LICENSE +21 -0
- package/README.md +131 -0
- package/dist/cli.js +340 -0
- package/dist/cli.js.map +1 -0
- package/dist/migrate.js +120 -0
- package/dist/migrate.js.map +1 -0
- package/dist/node.js +606 -0
- package/dist/node.js.map +1 -0
- package/dist/schema.sql +62 -0
- package/dist/server.js +851 -0
- package/dist/server.js.map +1 -0
- package/package.json +54 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../server/src/index.ts
|
|
4
|
+
import { createServer } from "http";
|
|
5
|
+
import { URL } from "url";
|
|
6
|
+
import { DBOS as DBOS2 } from "@dbos-inc/dbos-sdk";
|
|
7
|
+
import { ZodError } from "zod";
|
|
8
|
+
|
|
9
|
+
// ../core/src/config.ts
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
// ../core/src/env.ts
|
|
13
|
+
import { config as dotenvConfig } from "dotenv";
|
|
14
|
+
var envLoaded = false;
|
|
15
|
+
function loadLocalEnv() {
|
|
16
|
+
if (envLoaded) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
dotenvConfig({ path: ".env" });
|
|
20
|
+
dotenvConfig({ path: ".env.local", override: true });
|
|
21
|
+
envLoaded = true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ../core/src/config.ts
|
|
25
|
+
loadLocalEnv();
|
|
26
|
+
var serverEnvSchema = z.object({
|
|
27
|
+
DATABASE_URL: z.string().min(1),
|
|
28
|
+
WORKPLANE_SERVER_PORT: z.string().optional().default("8787").transform((value) => Number.parseInt(value, 10)),
|
|
29
|
+
WORKPLANE_NODE_TOKEN: z.string().optional(),
|
|
30
|
+
WORKPLANE_OPERATOR_TOKEN: z.string().optional(),
|
|
31
|
+
WORKPLANE_ENV_ALLOWLIST: z.string().optional()
|
|
32
|
+
});
|
|
33
|
+
var nodeEnvSchema = z.object({
|
|
34
|
+
WORKPLANE_SERVER_URL: z.string().url().optional().default("http://localhost:8787"),
|
|
35
|
+
WORKPLANE_NODE_NAME: z.string().optional().default("local-node-1"),
|
|
36
|
+
WORKPLANE_NODE_ID: z.string().optional(),
|
|
37
|
+
WORKPLANE_NODE_CAPABILITIES: z.string().optional().default("shell,git,node,typescript,aider,ollama,codex,claude-code"),
|
|
38
|
+
WORKPLANE_NODE_TOKEN: z.string().optional(),
|
|
39
|
+
WORKPLANE_POLL_INTERVAL_MS: z.string().optional().default("3000").transform((value) => Number.parseInt(value, 10)),
|
|
40
|
+
WORKPLANE_ENV_ALLOWLIST: z.string().optional()
|
|
41
|
+
});
|
|
42
|
+
function loadServerConfig(env = process.env) {
|
|
43
|
+
const parsed = serverEnvSchema.parse(env);
|
|
44
|
+
return {
|
|
45
|
+
databaseUrl: parsed.DATABASE_URL,
|
|
46
|
+
port: parsed.WORKPLANE_SERVER_PORT,
|
|
47
|
+
nodeToken: parsed.WORKPLANE_NODE_TOKEN,
|
|
48
|
+
operatorToken: parsed.WORKPLANE_OPERATOR_TOKEN,
|
|
49
|
+
envAllowlist: parseCsv(parsed.WORKPLANE_ENV_ALLOWLIST ?? "")
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function parseCsv(value) {
|
|
53
|
+
return value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ../db/src/client.ts
|
|
57
|
+
import { Pool } from "pg";
|
|
58
|
+
var pools = /* @__PURE__ */ new Map();
|
|
59
|
+
function getPool(databaseUrl) {
|
|
60
|
+
const existing = pools.get(databaseUrl);
|
|
61
|
+
if (existing) {
|
|
62
|
+
return existing;
|
|
63
|
+
}
|
|
64
|
+
const pool = new Pool({ connectionString: databaseUrl });
|
|
65
|
+
pools.set(databaseUrl, pool);
|
|
66
|
+
return pool;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ../core/src/ids.ts
|
|
70
|
+
import { randomUUID } from "crypto";
|
|
71
|
+
function makeId(prefix) {
|
|
72
|
+
return `${prefix}_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ../server/src/store.ts
|
|
76
|
+
function mapTask(row) {
|
|
77
|
+
return {
|
|
78
|
+
id: String(row.id),
|
|
79
|
+
kind: String(row.kind),
|
|
80
|
+
adapter: String(row.adapter),
|
|
81
|
+
payload: row.payload ?? {},
|
|
82
|
+
requires: row.requires ?? [],
|
|
83
|
+
status: row.status,
|
|
84
|
+
createdAt: new Date(String(row.created_at)).toISOString(),
|
|
85
|
+
updatedAt: new Date(String(row.updated_at)).toISOString()
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function mapRun(row) {
|
|
89
|
+
return {
|
|
90
|
+
id: String(row.id),
|
|
91
|
+
taskId: String(row.task_id),
|
|
92
|
+
nodeId: String(row.node_id),
|
|
93
|
+
attempt: Number(row.attempt),
|
|
94
|
+
status: row.status,
|
|
95
|
+
startedAt: row.started_at ? new Date(String(row.started_at)).toISOString() : null,
|
|
96
|
+
endedAt: row.ended_at ? new Date(String(row.ended_at)).toISOString() : null,
|
|
97
|
+
error: row.error ? String(row.error) : null
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function mapArtifact(row) {
|
|
101
|
+
return {
|
|
102
|
+
id: String(row.id),
|
|
103
|
+
runId: String(row.run_id),
|
|
104
|
+
type: String(row.type),
|
|
105
|
+
name: String(row.name),
|
|
106
|
+
path: String(row.path),
|
|
107
|
+
metadata: row.metadata ?? null,
|
|
108
|
+
createdAt: new Date(String(row.created_at)).toISOString()
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
var PgStore = class {
|
|
112
|
+
constructor(pool) {
|
|
113
|
+
this.pool = pool;
|
|
114
|
+
}
|
|
115
|
+
pool;
|
|
116
|
+
async createTask(input) {
|
|
117
|
+
const taskId = makeId("task");
|
|
118
|
+
const result = await this.pool.query(
|
|
119
|
+
`
|
|
120
|
+
insert into tasks (id, kind, adapter, payload, requires, status)
|
|
121
|
+
values ($1, $2, $3, $4::jsonb, $5::text[], 'queued')
|
|
122
|
+
returning *
|
|
123
|
+
`,
|
|
124
|
+
[taskId, input.kind, input.adapter, JSON.stringify(input.payload), input.requires ?? []]
|
|
125
|
+
);
|
|
126
|
+
return mapTask(result.rows[0]);
|
|
127
|
+
}
|
|
128
|
+
async listTasks(status) {
|
|
129
|
+
const result = status ? await this.pool.query("select * from tasks where status = $1 order by created_at desc limit 100", [status]) : await this.pool.query("select * from tasks order by created_at desc limit 100");
|
|
130
|
+
return result.rows.map((row) => mapTask(row));
|
|
131
|
+
}
|
|
132
|
+
async getTask(taskId) {
|
|
133
|
+
const result = await this.pool.query("select * from tasks where id = $1", [taskId]);
|
|
134
|
+
if (result.rowCount === 0) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return mapTask(result.rows[0]);
|
|
138
|
+
}
|
|
139
|
+
async listRuns(filters) {
|
|
140
|
+
const taskId = filters?.taskId;
|
|
141
|
+
const status = filters?.status;
|
|
142
|
+
const result = taskId && status ? await this.pool.query("select * from runs where task_id = $1 and status = $2 order by created_at desc", [
|
|
143
|
+
taskId,
|
|
144
|
+
status
|
|
145
|
+
]) : taskId ? await this.pool.query("select * from runs where task_id = $1 order by created_at desc", [taskId]) : status ? await this.pool.query("select * from runs where status = $1 order by created_at desc limit 100", [status]) : await this.pool.query("select * from runs order by created_at desc limit 100");
|
|
146
|
+
return result.rows.map((row) => mapRun(row));
|
|
147
|
+
}
|
|
148
|
+
async getRun(runId) {
|
|
149
|
+
const result = await this.pool.query("select * from runs where id = $1", [runId]);
|
|
150
|
+
if (result.rowCount === 0) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
return mapRun(result.rows[0]);
|
|
154
|
+
}
|
|
155
|
+
async registerNode(name, capabilities, preferredId) {
|
|
156
|
+
const existing = await this.pool.query("select * from nodes where name = $1", [name]);
|
|
157
|
+
if (existing.rowCount && existing.rowCount > 0) {
|
|
158
|
+
const row = existing.rows[0];
|
|
159
|
+
const nodeId2 = String(row.id);
|
|
160
|
+
const updated = await this.pool.query(
|
|
161
|
+
`
|
|
162
|
+
update nodes
|
|
163
|
+
set capabilities = $2::text[], status = 'online', last_heartbeat_at = now(), updated_at = now()
|
|
164
|
+
where id = $1
|
|
165
|
+
returning *
|
|
166
|
+
`,
|
|
167
|
+
[nodeId2, capabilities]
|
|
168
|
+
);
|
|
169
|
+
return this.mapNodeRecord(updated.rows[0]);
|
|
170
|
+
}
|
|
171
|
+
const nodeId = preferredId ?? makeId("node");
|
|
172
|
+
const result = await this.pool.query(
|
|
173
|
+
`
|
|
174
|
+
insert into nodes (id, name, capabilities, status, last_heartbeat_at)
|
|
175
|
+
values ($1, $2, $3::text[], 'online', now())
|
|
176
|
+
returning *
|
|
177
|
+
`,
|
|
178
|
+
[nodeId, name, capabilities]
|
|
179
|
+
);
|
|
180
|
+
return this.mapNodeRecord(result.rows[0]);
|
|
181
|
+
}
|
|
182
|
+
async getRunCancellationState(runId) {
|
|
183
|
+
const result = await this.pool.query(
|
|
184
|
+
`
|
|
185
|
+
select r.status as run_status, t.status as task_status
|
|
186
|
+
from runs r
|
|
187
|
+
join tasks t on t.id = r.task_id
|
|
188
|
+
where r.id = $1
|
|
189
|
+
`,
|
|
190
|
+
[runId]
|
|
191
|
+
);
|
|
192
|
+
if (result.rowCount === 0) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
const row = result.rows[0];
|
|
196
|
+
return {
|
|
197
|
+
runStatus: String(row.run_status),
|
|
198
|
+
taskStatus: String(row.task_status)
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
mapNodeRecord(row) {
|
|
202
|
+
return {
|
|
203
|
+
id: String(row.id),
|
|
204
|
+
name: String(row.name),
|
|
205
|
+
capabilities: row.capabilities ?? [],
|
|
206
|
+
status: row.status,
|
|
207
|
+
lastHeartbeatAt: row.last_heartbeat_at ? new Date(String(row.last_heartbeat_at)).toISOString() : null
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
async pollNode(nodeId, capabilities) {
|
|
211
|
+
const client = await this.pool.connect();
|
|
212
|
+
try {
|
|
213
|
+
await client.query("begin");
|
|
214
|
+
await this.touchNode(client, nodeId, capabilities);
|
|
215
|
+
const taskResult = await client.query(
|
|
216
|
+
`
|
|
217
|
+
select *
|
|
218
|
+
from tasks
|
|
219
|
+
where status = 'queued'
|
|
220
|
+
and requires <@ $1::text[]
|
|
221
|
+
order by created_at asc
|
|
222
|
+
limit 1
|
|
223
|
+
for update skip locked
|
|
224
|
+
`,
|
|
225
|
+
[capabilities]
|
|
226
|
+
);
|
|
227
|
+
if (taskResult.rowCount === 0) {
|
|
228
|
+
await client.query("commit");
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
const taskRow = taskResult.rows[0];
|
|
232
|
+
const taskId = String(taskRow.id);
|
|
233
|
+
const runId = makeId("run");
|
|
234
|
+
const attemptResult = await client.query("select coalesce(max(attempt), 0) as max_attempt from runs where task_id = $1", [
|
|
235
|
+
taskId
|
|
236
|
+
]);
|
|
237
|
+
const attempt = Number(attemptResult.rows[0].max_attempt) + 1;
|
|
238
|
+
const runResult = await client.query(
|
|
239
|
+
`
|
|
240
|
+
insert into runs (id, task_id, node_id, attempt, status, started_at)
|
|
241
|
+
values ($1, $2, $3, $4, 'assigned', now())
|
|
242
|
+
returning *
|
|
243
|
+
`,
|
|
244
|
+
[runId, taskId, nodeId, attempt]
|
|
245
|
+
);
|
|
246
|
+
await client.query("update tasks set status = 'assigned', updated_at = now() where id = $1", [taskId]);
|
|
247
|
+
await client.query("commit");
|
|
248
|
+
return {
|
|
249
|
+
task: mapTask(taskRow),
|
|
250
|
+
run: mapRun(runResult.rows[0])
|
|
251
|
+
};
|
|
252
|
+
} catch (error) {
|
|
253
|
+
await client.query("rollback");
|
|
254
|
+
throw error;
|
|
255
|
+
} finally {
|
|
256
|
+
client.release();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async updateRunStatus(runId, status, error) {
|
|
260
|
+
const client = await this.pool.connect();
|
|
261
|
+
try {
|
|
262
|
+
await client.query("begin");
|
|
263
|
+
const currentResult = await client.query(
|
|
264
|
+
`
|
|
265
|
+
select r.*, t.status as task_status
|
|
266
|
+
from runs r
|
|
267
|
+
join tasks t on t.id = r.task_id
|
|
268
|
+
where r.id = $1
|
|
269
|
+
for update
|
|
270
|
+
`,
|
|
271
|
+
[runId]
|
|
272
|
+
);
|
|
273
|
+
if (currentResult.rowCount === 0) {
|
|
274
|
+
await client.query("rollback");
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
const currentRow = currentResult.rows[0];
|
|
278
|
+
const currentTaskStatus = String(currentRow.task_status);
|
|
279
|
+
if (currentTaskStatus === "cancelled" && status !== "cancelled") {
|
|
280
|
+
await client.query("commit");
|
|
281
|
+
return mapRun(currentRow);
|
|
282
|
+
}
|
|
283
|
+
const runResult = await client.query(
|
|
284
|
+
`
|
|
285
|
+
update runs
|
|
286
|
+
set status = $2, error = $3, ended_at = case when $2 in ('succeeded', 'failed', 'cancelled') then now() else ended_at end, updated_at = now()
|
|
287
|
+
where id = $1
|
|
288
|
+
returning *
|
|
289
|
+
`,
|
|
290
|
+
[runId, status, error ?? null]
|
|
291
|
+
);
|
|
292
|
+
if (runResult.rowCount === 0) {
|
|
293
|
+
await client.query("rollback");
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
const run = mapRun(runResult.rows[0]);
|
|
297
|
+
const nextTaskStatus = status === "succeeded" || status === "failed" || status === "cancelled" ? status : "running";
|
|
298
|
+
if (currentTaskStatus !== "cancelled" || status === "cancelled") {
|
|
299
|
+
await client.query("update tasks set status = $2, updated_at = now() where id = $1", [run.taskId, nextTaskStatus]);
|
|
300
|
+
}
|
|
301
|
+
await client.query("commit");
|
|
302
|
+
return run;
|
|
303
|
+
} catch (error2) {
|
|
304
|
+
await client.query("rollback");
|
|
305
|
+
throw error2;
|
|
306
|
+
} finally {
|
|
307
|
+
client.release();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async appendRunLogs(runId, logs) {
|
|
311
|
+
let inserted = 0;
|
|
312
|
+
for (const log of logs) {
|
|
313
|
+
await this.pool.query(
|
|
314
|
+
`
|
|
315
|
+
insert into run_logs (run_id, step_name, stream, message)
|
|
316
|
+
values ($1, $2, $3, $4)
|
|
317
|
+
`,
|
|
318
|
+
[runId, log.stepName ?? null, log.stream, log.message]
|
|
319
|
+
);
|
|
320
|
+
inserted += 1;
|
|
321
|
+
}
|
|
322
|
+
return inserted;
|
|
323
|
+
}
|
|
324
|
+
async getRunLogs(runId) {
|
|
325
|
+
const result = await this.pool.query("select * from run_logs where run_id = $1 order by timestamp asc", [runId]);
|
|
326
|
+
return result.rows;
|
|
327
|
+
}
|
|
328
|
+
async createArtifact(runId, input) {
|
|
329
|
+
const artifactId = makeId("artifact");
|
|
330
|
+
const result = await this.pool.query(
|
|
331
|
+
`
|
|
332
|
+
insert into artifacts (id, run_id, type, name, path, metadata)
|
|
333
|
+
values ($1, $2, $3, $4, $5, $6::jsonb)
|
|
334
|
+
returning *
|
|
335
|
+
`,
|
|
336
|
+
[artifactId, runId, input.type, input.name, input.path, input.metadata ? JSON.stringify(input.metadata) : null]
|
|
337
|
+
);
|
|
338
|
+
return mapArtifact(result.rows[0]);
|
|
339
|
+
}
|
|
340
|
+
async listRunArtifacts(runId) {
|
|
341
|
+
const result = await this.pool.query("select * from artifacts where run_id = $1 order by created_at asc", [runId]);
|
|
342
|
+
return result.rows.map((row) => mapArtifact(row));
|
|
343
|
+
}
|
|
344
|
+
async retryTask(taskId) {
|
|
345
|
+
const result = await this.pool.query(
|
|
346
|
+
`
|
|
347
|
+
update tasks
|
|
348
|
+
set status = 'queued', updated_at = now()
|
|
349
|
+
where id = $1 and status = 'failed'
|
|
350
|
+
returning *
|
|
351
|
+
`,
|
|
352
|
+
[taskId]
|
|
353
|
+
);
|
|
354
|
+
if (result.rowCount === 0) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
return mapTask(result.rows[0]);
|
|
358
|
+
}
|
|
359
|
+
async cancelTask(taskId) {
|
|
360
|
+
const client = await this.pool.connect();
|
|
361
|
+
try {
|
|
362
|
+
await client.query("begin");
|
|
363
|
+
const taskResult = await client.query("select * from tasks where id = $1 for update", [taskId]);
|
|
364
|
+
if (taskResult.rowCount === 0) {
|
|
365
|
+
await client.query("rollback");
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
const taskRow = taskResult.rows[0];
|
|
369
|
+
const taskStatus = String(taskRow.status);
|
|
370
|
+
if (taskStatus === "succeeded" || taskStatus === "failed" || taskStatus === "cancelled") {
|
|
371
|
+
await client.query("rollback");
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
if (taskStatus === "assigned" || taskStatus === "running") {
|
|
375
|
+
const runResult = await client.query(
|
|
376
|
+
`
|
|
377
|
+
select * from runs
|
|
378
|
+
where task_id = $1
|
|
379
|
+
order by attempt desc
|
|
380
|
+
limit 1
|
|
381
|
+
for update
|
|
382
|
+
`,
|
|
383
|
+
[taskId]
|
|
384
|
+
);
|
|
385
|
+
if (runResult.rowCount !== 0) {
|
|
386
|
+
const runStatus = String(runResult.rows[0].status);
|
|
387
|
+
if (runStatus !== "succeeded" && runStatus !== "failed" && runStatus !== "cancelled") {
|
|
388
|
+
await client.query(
|
|
389
|
+
`
|
|
390
|
+
update runs
|
|
391
|
+
set status = 'cancelled', ended_at = now(), updated_at = now(), error = coalesce(error, 'task cancelled')
|
|
392
|
+
where id = $1
|
|
393
|
+
`,
|
|
394
|
+
[runResult.rows[0].id]
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const updateTaskResult = await client.query(
|
|
400
|
+
`
|
|
401
|
+
update tasks
|
|
402
|
+
set status = 'cancelled', updated_at = now()
|
|
403
|
+
where id = $1
|
|
404
|
+
returning *
|
|
405
|
+
`,
|
|
406
|
+
[taskId]
|
|
407
|
+
);
|
|
408
|
+
await client.query("commit");
|
|
409
|
+
return mapTask(updateTaskResult.rows[0]);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
await client.query("rollback");
|
|
412
|
+
throw error;
|
|
413
|
+
} finally {
|
|
414
|
+
client.release();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
async touchNode(client, nodeId, capabilities) {
|
|
418
|
+
await client.query(
|
|
419
|
+
`
|
|
420
|
+
update nodes
|
|
421
|
+
set capabilities = $2::text[], status = 'online', last_heartbeat_at = now(), updated_at = now()
|
|
422
|
+
where id = $1
|
|
423
|
+
`,
|
|
424
|
+
[nodeId, capabilities]
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// ../server/src/workflows.ts
|
|
430
|
+
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
431
|
+
function registerWorkflows(store) {
|
|
432
|
+
const createTask = DBOS.registerWorkflow(
|
|
433
|
+
async (input) => DBOS.runStep(() => store.createTask(input), {
|
|
434
|
+
name: "store-create-task"
|
|
435
|
+
}),
|
|
436
|
+
{ name: "createTaskWorkflow" }
|
|
437
|
+
);
|
|
438
|
+
const retryTask = DBOS.registerWorkflow(
|
|
439
|
+
async (taskId) => DBOS.runStep(() => store.retryTask(taskId), {
|
|
440
|
+
name: "store-retry-task"
|
|
441
|
+
}),
|
|
442
|
+
{ name: "retryTaskWorkflow" }
|
|
443
|
+
);
|
|
444
|
+
const cancelTask = DBOS.registerWorkflow(
|
|
445
|
+
async (taskId) => DBOS.runStep(() => store.cancelTask(taskId), {
|
|
446
|
+
name: "store-cancel-task"
|
|
447
|
+
}),
|
|
448
|
+
{ name: "cancelTaskWorkflow" }
|
|
449
|
+
);
|
|
450
|
+
const updateRunStatus = DBOS.registerWorkflow(
|
|
451
|
+
async (runId, status, error) => DBOS.runStep(() => store.updateRunStatus(runId, status, error), {
|
|
452
|
+
name: "store-update-run-status"
|
|
453
|
+
}),
|
|
454
|
+
{ name: "updateRunStatusWorkflow" }
|
|
455
|
+
);
|
|
456
|
+
const appendRunLogs = DBOS.registerWorkflow(
|
|
457
|
+
async (runId, logs) => DBOS.runStep(() => store.appendRunLogs(runId, logs), {
|
|
458
|
+
name: "store-append-run-logs"
|
|
459
|
+
}),
|
|
460
|
+
{ name: "appendRunLogsWorkflow" }
|
|
461
|
+
);
|
|
462
|
+
const createArtifact = DBOS.registerWorkflow(
|
|
463
|
+
async (runId, input) => DBOS.runStep(() => store.createArtifact(runId, input), {
|
|
464
|
+
name: "store-create-artifact"
|
|
465
|
+
}),
|
|
466
|
+
{ name: "createArtifactWorkflow" }
|
|
467
|
+
);
|
|
468
|
+
return {
|
|
469
|
+
createTask,
|
|
470
|
+
retryTask,
|
|
471
|
+
cancelTask,
|
|
472
|
+
updateRunStatus,
|
|
473
|
+
appendRunLogs,
|
|
474
|
+
createArtifact
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ../server/src/validation.ts
|
|
479
|
+
import { z as z2 } from "zod";
|
|
480
|
+
var repoFields = {
|
|
481
|
+
repo: z2.string().min(1).optional(),
|
|
482
|
+
branch: z2.string().min(1).optional()
|
|
483
|
+
};
|
|
484
|
+
var shellTaskSchema = z2.object({
|
|
485
|
+
kind: z2.literal("shell.exec"),
|
|
486
|
+
adapter: z2.literal("shell"),
|
|
487
|
+
requires: z2.array(z2.string().min(1)).optional(),
|
|
488
|
+
payload: z2.object({
|
|
489
|
+
command: z2.string().min(1),
|
|
490
|
+
args: z2.array(z2.string()).optional(),
|
|
491
|
+
cwd: z2.string().min(1).optional(),
|
|
492
|
+
...repoFields
|
|
493
|
+
})
|
|
494
|
+
});
|
|
495
|
+
var aiderTaskSchema = z2.object({
|
|
496
|
+
kind: z2.literal("agent.run"),
|
|
497
|
+
adapter: z2.literal("aider"),
|
|
498
|
+
requires: z2.array(z2.string().min(1)).optional(),
|
|
499
|
+
payload: z2.object({
|
|
500
|
+
prompt: z2.string().min(1),
|
|
501
|
+
model: z2.string().min(1).optional(),
|
|
502
|
+
repo: z2.string().min(1),
|
|
503
|
+
branch: z2.string().min(1).optional(),
|
|
504
|
+
testCommand: z2.string().min(1).optional()
|
|
505
|
+
})
|
|
506
|
+
});
|
|
507
|
+
var ollamaTaskSchema = z2.object({
|
|
508
|
+
kind: z2.literal("inference.batch"),
|
|
509
|
+
adapter: z2.literal("ollama"),
|
|
510
|
+
requires: z2.array(z2.string().min(1)).optional(),
|
|
511
|
+
payload: z2.object({
|
|
512
|
+
model: z2.string().min(1),
|
|
513
|
+
prompt: z2.string().min(1),
|
|
514
|
+
inputFile: z2.string().min(1).optional()
|
|
515
|
+
})
|
|
516
|
+
});
|
|
517
|
+
var harnessPayloadSchema = z2.object({
|
|
518
|
+
prompt: z2.string().min(1),
|
|
519
|
+
model: z2.string().min(1).optional(),
|
|
520
|
+
repo: z2.string().min(1),
|
|
521
|
+
branch: z2.string().min(1).optional(),
|
|
522
|
+
testCommand: z2.string().min(1).optional(),
|
|
523
|
+
extraArgs: z2.array(z2.string()).optional()
|
|
524
|
+
});
|
|
525
|
+
var codexTaskSchema = z2.object({
|
|
526
|
+
kind: z2.literal("agent.run"),
|
|
527
|
+
adapter: z2.literal("codex"),
|
|
528
|
+
requires: z2.array(z2.string().min(1)).optional(),
|
|
529
|
+
payload: harnessPayloadSchema
|
|
530
|
+
});
|
|
531
|
+
var claudeCodeTaskSchema = z2.object({
|
|
532
|
+
kind: z2.literal("agent.run"),
|
|
533
|
+
adapter: z2.literal("claude-code"),
|
|
534
|
+
requires: z2.array(z2.string().min(1)).optional(),
|
|
535
|
+
payload: harnessPayloadSchema
|
|
536
|
+
});
|
|
537
|
+
var taskSchema = z2.discriminatedUnion("adapter", [
|
|
538
|
+
shellTaskSchema,
|
|
539
|
+
aiderTaskSchema,
|
|
540
|
+
ollamaTaskSchema,
|
|
541
|
+
codexTaskSchema,
|
|
542
|
+
claudeCodeTaskSchema
|
|
543
|
+
]);
|
|
544
|
+
function validateCreateTaskInput(input) {
|
|
545
|
+
return taskSchema.parse(input);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ../core/src/auth.ts
|
|
549
|
+
function parseBearerToken(authorizationHeader) {
|
|
550
|
+
if (!authorizationHeader) {
|
|
551
|
+
return void 0;
|
|
552
|
+
}
|
|
553
|
+
const match = /^Bearer\s+(.+)$/i.exec(authorizationHeader.trim());
|
|
554
|
+
return match?.[1];
|
|
555
|
+
}
|
|
556
|
+
function isAuthorizedBearer(provided, expected) {
|
|
557
|
+
if (!expected) {
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
if (!provided) {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
return provided === expected;
|
|
564
|
+
}
|
|
565
|
+
function getRequestBearer(req) {
|
|
566
|
+
const header = req.headers.authorization;
|
|
567
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
568
|
+
return parseBearerToken(value);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ../server/src/auth-middleware.ts
|
|
572
|
+
function checkRouteAuth(req, res, auth, config) {
|
|
573
|
+
const bearer = getRequestBearer(req);
|
|
574
|
+
if (auth === "public") {
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
if (auth === "node") {
|
|
578
|
+
if (!isAuthorizedBearer(bearer, config.nodeToken)) {
|
|
579
|
+
res.statusCode = 401;
|
|
580
|
+
res.setHeader("content-type", "application/json");
|
|
581
|
+
res.end(JSON.stringify({ error: "unauthorized node request" }));
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
if (auth === "operator") {
|
|
587
|
+
if (!isAuthorizedBearer(bearer, config.operatorToken)) {
|
|
588
|
+
res.statusCode = 401;
|
|
589
|
+
res.setHeader("content-type", "application/json");
|
|
590
|
+
res.end(JSON.stringify({ error: "unauthorized operator request" }));
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
function routeAuthFor(method, pathname) {
|
|
598
|
+
if (pathname === "/healthz") {
|
|
599
|
+
return "public";
|
|
600
|
+
}
|
|
601
|
+
if (method === "POST" && pathname === "/nodes/register") {
|
|
602
|
+
return "node";
|
|
603
|
+
}
|
|
604
|
+
if (method === "POST" && /^\/nodes\/[^/]+\/poll$/.test(pathname)) {
|
|
605
|
+
return "node";
|
|
606
|
+
}
|
|
607
|
+
if (method === "POST" && /^\/runs\/[^/]+\/status$/.test(pathname)) {
|
|
608
|
+
return "node";
|
|
609
|
+
}
|
|
610
|
+
if (method === "POST" && /^\/runs\/[^/]+\/logs$/.test(pathname)) {
|
|
611
|
+
return "node";
|
|
612
|
+
}
|
|
613
|
+
if (method === "POST" && /^\/runs\/[^/]+\/artifacts$/.test(pathname)) {
|
|
614
|
+
return "node";
|
|
615
|
+
}
|
|
616
|
+
if (method === "POST" && pathname === "/tasks") {
|
|
617
|
+
return "operator";
|
|
618
|
+
}
|
|
619
|
+
if (method === "POST" && /^\/tasks\/[^/]+\/retry$/.test(pathname)) {
|
|
620
|
+
return "operator";
|
|
621
|
+
}
|
|
622
|
+
if (method === "POST" && /^\/tasks\/[^/]+\/cancel$/.test(pathname)) {
|
|
623
|
+
return "operator";
|
|
624
|
+
}
|
|
625
|
+
return "public";
|
|
626
|
+
}
|
|
627
|
+
function requiresConfiguredToken(auth, config) {
|
|
628
|
+
if (auth === "node") {
|
|
629
|
+
return Boolean(config.nodeToken);
|
|
630
|
+
}
|
|
631
|
+
if (auth === "operator") {
|
|
632
|
+
return Boolean(config.operatorToken);
|
|
633
|
+
}
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ../server/src/index.ts
|
|
638
|
+
var taskStatuses = /* @__PURE__ */ new Set(["queued", "assigned", "running", "succeeded", "failed", "cancelled"]);
|
|
639
|
+
function parseStatus(value) {
|
|
640
|
+
if (!value) {
|
|
641
|
+
return void 0;
|
|
642
|
+
}
|
|
643
|
+
if (!taskStatuses.has(value)) {
|
|
644
|
+
throw new Error(`invalid status filter: ${value}`);
|
|
645
|
+
}
|
|
646
|
+
return value;
|
|
647
|
+
}
|
|
648
|
+
async function readJson(req) {
|
|
649
|
+
const chunks = [];
|
|
650
|
+
for await (const chunk of req) {
|
|
651
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
652
|
+
}
|
|
653
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
654
|
+
return body ? JSON.parse(body) : {};
|
|
655
|
+
}
|
|
656
|
+
function writeJson(res, statusCode, payload) {
|
|
657
|
+
const json = JSON.stringify(payload);
|
|
658
|
+
res.statusCode = statusCode;
|
|
659
|
+
res.setHeader("content-type", "application/json");
|
|
660
|
+
res.end(json);
|
|
661
|
+
}
|
|
662
|
+
async function main() {
|
|
663
|
+
const config = loadServerConfig();
|
|
664
|
+
const pool = getPool(config.databaseUrl);
|
|
665
|
+
const store = new PgStore(pool);
|
|
666
|
+
const workflows = registerWorkflows(store);
|
|
667
|
+
const appName = process.env.DBOS_APPLICATION_NAME ?? "workplane-server";
|
|
668
|
+
const conductorKey = process.env.DBOS_CONDUCTOR_KEY;
|
|
669
|
+
const conductorUrl = process.env.DBOS_CONDUCTOR_URL;
|
|
670
|
+
DBOS2.setConfig({
|
|
671
|
+
name: appName,
|
|
672
|
+
systemDatabaseUrl: config.databaseUrl
|
|
673
|
+
});
|
|
674
|
+
await DBOS2.launch({
|
|
675
|
+
conductorKey,
|
|
676
|
+
conductorURL: conductorUrl
|
|
677
|
+
});
|
|
678
|
+
const server = createServer(async (req, res) => {
|
|
679
|
+
try {
|
|
680
|
+
if (!req.url || !req.method) {
|
|
681
|
+
writeJson(res, 400, { error: "invalid request" });
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const url = new URL(req.url, "http://localhost");
|
|
685
|
+
const routeAuth = routeAuthFor(req.method, url.pathname);
|
|
686
|
+
const authConfig = { nodeToken: config.nodeToken, operatorToken: config.operatorToken };
|
|
687
|
+
if (requiresConfiguredToken(routeAuth, authConfig) && !checkRouteAuth(req, res, routeAuth, authConfig)) {
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (req.method === "GET" && url.pathname === "/healthz") {
|
|
691
|
+
writeJson(res, 200, { ok: true });
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (req.method === "POST" && url.pathname === "/tasks") {
|
|
695
|
+
const body = await readJson(req);
|
|
696
|
+
const validated = validateCreateTaskInput(body);
|
|
697
|
+
const handle = await DBOS2.startWorkflow(workflows.createTask)({
|
|
698
|
+
kind: validated.kind,
|
|
699
|
+
adapter: validated.adapter,
|
|
700
|
+
payload: validated.payload,
|
|
701
|
+
requires: validated.requires ?? []
|
|
702
|
+
});
|
|
703
|
+
const task = await handle.getResult();
|
|
704
|
+
writeJson(res, 201, task);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (req.method === "GET" && url.pathname === "/tasks") {
|
|
708
|
+
const status = parseStatus(url.searchParams.get("status"));
|
|
709
|
+
const tasks = await store.listTasks(status);
|
|
710
|
+
writeJson(res, 200, { tasks });
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (req.method === "GET" && /^\/tasks\/[^/]+$/.test(url.pathname)) {
|
|
714
|
+
const taskId = url.pathname.split("/")[2];
|
|
715
|
+
const task = await store.getTask(taskId);
|
|
716
|
+
if (!task) {
|
|
717
|
+
writeJson(res, 404, { error: "task not found" });
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
writeJson(res, 200, task);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (req.method === "POST" && /^\/tasks\/[^/]+\/retry$/.test(url.pathname)) {
|
|
724
|
+
const taskId = url.pathname.split("/")[2];
|
|
725
|
+
const handle = await DBOS2.startWorkflow(workflows.retryTask)(taskId);
|
|
726
|
+
const task = await handle.getResult();
|
|
727
|
+
if (!task) {
|
|
728
|
+
writeJson(res, 409, { error: "task is not retryable" });
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
writeJson(res, 200, task);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (req.method === "POST" && /^\/tasks\/[^/]+\/cancel$/.test(url.pathname)) {
|
|
735
|
+
const taskId = url.pathname.split("/")[2];
|
|
736
|
+
const handle = await DBOS2.startWorkflow(workflows.cancelTask)(taskId);
|
|
737
|
+
const task = await handle.getResult();
|
|
738
|
+
if (!task) {
|
|
739
|
+
writeJson(res, 409, { error: "task is not cancellable" });
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
writeJson(res, 200, task);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (req.method === "GET" && url.pathname === "/runs") {
|
|
746
|
+
const taskId = url.searchParams.get("taskId") ?? void 0;
|
|
747
|
+
const status = parseStatus(url.searchParams.get("status"));
|
|
748
|
+
const runs = await store.listRuns({ taskId, status });
|
|
749
|
+
writeJson(res, 200, { runs });
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (req.method === "GET" && /^\/runs\/[^/]+$/.test(url.pathname)) {
|
|
753
|
+
const runId = url.pathname.split("/")[2];
|
|
754
|
+
const run = await store.getRun(runId);
|
|
755
|
+
if (!run) {
|
|
756
|
+
writeJson(res, 404, { error: "run not found" });
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
writeJson(res, 200, run);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (req.method === "GET" && /^\/runs\/[^/]+\/logs$/.test(url.pathname)) {
|
|
763
|
+
const runId = url.pathname.split("/")[2];
|
|
764
|
+
const logs = await store.getRunLogs(runId);
|
|
765
|
+
writeJson(res, 200, { logs });
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (req.method === "GET" && /^\/runs\/[^/]+\/artifacts$/.test(url.pathname)) {
|
|
769
|
+
const runId = url.pathname.split("/")[2];
|
|
770
|
+
const artifacts = await store.listRunArtifacts(runId);
|
|
771
|
+
writeJson(res, 200, { artifacts });
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
if (req.method === "POST" && url.pathname === "/nodes/register") {
|
|
775
|
+
const body = await readJson(req);
|
|
776
|
+
const node = await store.registerNode(body.name, body.capabilities ?? [], body.nodeId);
|
|
777
|
+
writeJson(res, 201, node);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
if (req.method === "POST" && /^\/nodes\/[^/]+\/poll$/.test(url.pathname)) {
|
|
781
|
+
const nodeId = url.pathname.split("/")[2];
|
|
782
|
+
const body = await readJson(req);
|
|
783
|
+
const assignment = await store.pollNode(nodeId, body.capabilities ?? []);
|
|
784
|
+
writeJson(res, 200, { assignment });
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (req.method === "POST" && /^\/runs\/[^/]+\/status$/.test(url.pathname)) {
|
|
788
|
+
const runId = url.pathname.split("/")[2];
|
|
789
|
+
const body = await readJson(req);
|
|
790
|
+
const handle = await DBOS2.startWorkflow(workflows.updateRunStatus)(runId, body.status, body.error);
|
|
791
|
+
const run = await handle.getResult();
|
|
792
|
+
if (!run) {
|
|
793
|
+
writeJson(res, 404, { error: "run not found" });
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
writeJson(res, 200, run);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
if (req.method === "POST" && /^\/runs\/[^/]+\/logs$/.test(url.pathname)) {
|
|
800
|
+
const runId = url.pathname.split("/")[2];
|
|
801
|
+
const body = await readJson(req);
|
|
802
|
+
const handle = await DBOS2.startWorkflow(workflows.appendRunLogs)(runId, body.logs ?? []);
|
|
803
|
+
const inserted = await handle.getResult();
|
|
804
|
+
writeJson(res, 200, { inserted });
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (req.method === "POST" && /^\/runs\/[^/]+\/artifacts$/.test(url.pathname)) {
|
|
808
|
+
const runId = url.pathname.split("/")[2];
|
|
809
|
+
const body = await readJson(req);
|
|
810
|
+
const handle = await DBOS2.startWorkflow(workflows.createArtifact)(runId, body);
|
|
811
|
+
const artifact = await handle.getResult();
|
|
812
|
+
writeJson(res, 201, artifact);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
writeJson(res, 404, { error: "not found" });
|
|
816
|
+
} catch (error) {
|
|
817
|
+
if (error instanceof ZodError) {
|
|
818
|
+
writeJson(res, 400, { error: "invalid task payload", details: error });
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (error instanceof Error && error.message.startsWith("invalid status filter:")) {
|
|
822
|
+
writeJson(res, 400, { error: error.message });
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const message = error instanceof Error ? error.message : "internal error";
|
|
826
|
+
writeJson(res, 500, { error: message });
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
server.listen(config.port, () => {
|
|
830
|
+
process.stdout.write(`workplane server listening on http://localhost:${config.port}
|
|
831
|
+
`);
|
|
832
|
+
});
|
|
833
|
+
const shutdown = async () => {
|
|
834
|
+
await DBOS2.shutdown();
|
|
835
|
+
await pool.end();
|
|
836
|
+
server.close();
|
|
837
|
+
process.exit(0);
|
|
838
|
+
};
|
|
839
|
+
process.on("SIGINT", () => {
|
|
840
|
+
void shutdown();
|
|
841
|
+
});
|
|
842
|
+
process.on("SIGTERM", () => {
|
|
843
|
+
void shutdown();
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
main().catch((error) => {
|
|
847
|
+
process.stderr.write(`${String(error)}
|
|
848
|
+
`);
|
|
849
|
+
process.exit(1);
|
|
850
|
+
});
|
|
851
|
+
//# sourceMappingURL=server.js.map
|