pragma-so 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/conversation/prompts.js +2 -2
- package/dist/server/db.js +18 -0
- package/dist/server/http/schemas.js +19 -2
- package/dist/server/index.js +356 -27
- package/package.json +1 -1
- package/ui/dist/assets/{index-Dr1FqdaF.js → index-BLSWmSs5.js} +115 -110
- package/ui/dist/assets/index-BSHDYxH8.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-DEkzJ5rp.css +0 -1
|
@@ -184,8 +184,8 @@ function buildWorkerPrompt(input) {
|
|
|
184
184
|
"If you changed code, submit at least one runnable validation command for the task window.",
|
|
185
185
|
`For richer testing UIs with multiple processes and panels, use \`submit-testing-config\`:`,
|
|
186
186
|
submitTestingConfigCommand,
|
|
187
|
-
`The config JSON has: \`processes\` (array of {name, command, cwd?,
|
|
188
|
-
`Example: \`--config '{"processes":[{"name":"server","command":"npm run dev","cwd":"code/my-app","
|
|
187
|
+
`The config JSON has: \`processes\` (array of {name, command, cwd?, ready_pattern?}) and \`panels\` (array of panel objects). Panel types: \`web-preview\` ({type, title, process, path?, devices?}), \`api-tester\` ({type, title, process, endpoints: [{method, path, description?, body?, headers?}]}), \`terminal\` ({type, title, command, cwd?}), \`log-viewer\` ({type, title, process}). Optional: \`setup\` (array of setup commands), \`layout\` ("tabs"|"grid").`,
|
|
188
|
+
`Example: \`--config '{"processes":[{"name":"server","command":"npm run dev","cwd":"code/my-app","ready_pattern":"ready on"}],"panels":[{"type":"web-preview","title":"App","process":"server"}]}'\``,
|
|
189
189
|
`Fallback: for simple single-command cases, use:`,
|
|
190
190
|
"Include the exact run directory for each command (for example: `--cwd \"code/default/my-app\"`):",
|
|
191
191
|
submitTestsCommand,
|
package/dist/server/db.js
CHANGED
|
@@ -563,6 +563,24 @@ CREATE TABLE IF NOT EXISTS agent_connectors (
|
|
|
563
563
|
connector_id VARCHAR(64) REFERENCES connectors(id) ON DELETE CASCADE,
|
|
564
564
|
PRIMARY KEY (agent_id, connector_id)
|
|
565
565
|
);
|
|
566
|
+
`);
|
|
567
|
+
await db.exec(`
|
|
568
|
+
CREATE TABLE IF NOT EXISTS processes (
|
|
569
|
+
id VARCHAR(64) PRIMARY KEY,
|
|
570
|
+
workspace VARCHAR(255) NOT NULL,
|
|
571
|
+
folder_name VARCHAR(255) NOT NULL,
|
|
572
|
+
label VARCHAR(255) NOT NULL,
|
|
573
|
+
command TEXT NOT NULL,
|
|
574
|
+
cwd TEXT NOT NULL,
|
|
575
|
+
type VARCHAR(16) NOT NULL DEFAULT 'service',
|
|
576
|
+
status VARCHAR(32) NOT NULL DEFAULT 'stopped',
|
|
577
|
+
pid INTEGER,
|
|
578
|
+
exit_code INTEGER,
|
|
579
|
+
task_id VARCHAR(64),
|
|
580
|
+
started_at TIMESTAMPTZ,
|
|
581
|
+
stopped_at TIMESTAMPTZ,
|
|
582
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
583
|
+
);
|
|
566
584
|
`);
|
|
567
585
|
}
|
|
568
586
|
async function ensureTaskStatusEnumType(db) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.dbQuerySchema = exports.assignAgentConnectorSchema = exports.configureConnectorSchema = exports.assignAgentSkillSchema = exports.updateSkillSchema = exports.createSkillSchema = exports.updateHumanSchema = exports.createHumanSchema = exports.updateContextFileSchema = exports.createContextFileSchema = exports.serviceStdinSchema = exports.testingProxyRequestSchema = exports.agentSubmitTestingConfigSchema = exports.testingConfigSchema = exports.runTaskTestCommandSchema = exports.createCodeFolderCopySchema = exports.createCodeRepoCloneSchema = exports.createContextFolderSchema = exports.plansQuerySchema = exports.chatsQuerySchema = exports.executeFromThreadSchema = exports.conversationTurnSchema = exports.outputFileQuerySchema = exports.openOutputFolderSchema = exports.reviewTaskSchema = exports.stopTaskSchema = exports.taskRespondSchema = exports.updateTaskTestCommandsSchema = exports.agentSubmitTestCommandsSchema = exports.agentRequestHelpSchema = exports.agentAskQuestionSchema = exports.executePlanProposalSchema = exports.planProposeSchema = exports.planSelectRecipientSchema = exports.agentSelectRecipientSchema = exports.setTaskRecipientSchema = exports.createFollowupTaskSchema = exports.createExecuteTaskSchema = exports.createTaskSchema = exports.tasksQuerySchema = exports.updateAgentSchema = exports.createAgentSchema = exports.setActiveWorkspaceSchema = exports.createWorkspaceSchema = void 0;
|
|
3
|
+
exports.updateProcessSchema = exports.createProcessSchema = exports.dbQuerySchema = exports.assignAgentConnectorSchema = exports.configureConnectorSchema = exports.assignAgentSkillSchema = exports.updateSkillSchema = exports.createSkillSchema = exports.updateHumanSchema = exports.createHumanSchema = exports.updateContextFileSchema = exports.createContextFileSchema = exports.serviceStdinSchema = exports.testingProxyRequestSchema = exports.agentSubmitTestingConfigSchema = exports.testingConfigSchema = exports.runTaskTestCommandSchema = exports.createCodeFolderCopySchema = exports.createCodeRepoCloneSchema = exports.createContextFolderSchema = exports.plansQuerySchema = exports.chatsQuerySchema = exports.executeFromThreadSchema = exports.conversationTurnSchema = exports.outputFileQuerySchema = exports.openOutputFolderSchema = exports.reviewTaskSchema = exports.stopTaskSchema = exports.taskRespondSchema = exports.updateTaskTestCommandsSchema = exports.agentSubmitTestCommandsSchema = exports.agentRequestHelpSchema = exports.agentAskQuestionSchema = exports.executePlanProposalSchema = exports.planProposeSchema = exports.planSelectRecipientSchema = exports.agentSelectRecipientSchema = exports.setTaskRecipientSchema = exports.createFollowupTaskSchema = exports.createExecuteTaskSchema = exports.createTaskSchema = exports.tasksQuerySchema = exports.updateAgentSchema = exports.createAgentSchema = exports.setActiveWorkspaceSchema = exports.createWorkspaceSchema = void 0;
|
|
4
4
|
const zod_1 = require("zod");
|
|
5
5
|
const types_1 = require("../conversation/types");
|
|
6
6
|
const adapterRegistry_1 = require("../conversation/adapterRegistry");
|
|
@@ -242,7 +242,7 @@ const testingProcessSchema = zod_1.z.object({
|
|
|
242
242
|
name: nonEmptyString,
|
|
243
243
|
command: nonEmptyString,
|
|
244
244
|
cwd: nonEmptyString.optional(),
|
|
245
|
-
port: zod_1.z.number().int().positive().optional(),
|
|
245
|
+
port: zod_1.z.number().int().positive().optional(), // Ignored — server assigns ports automatically
|
|
246
246
|
healthcheck: zod_1.z.string().optional(),
|
|
247
247
|
ready_pattern: zod_1.z.string().optional(),
|
|
248
248
|
}).strict();
|
|
@@ -366,3 +366,20 @@ exports.dbQuerySchema = zod_1.z
|
|
|
366
366
|
params: zod_1.z.array(zod_1.z.unknown()).optional(),
|
|
367
367
|
})
|
|
368
368
|
.strict();
|
|
369
|
+
const processTypeSchema = zod_1.z.enum(["service", "script"]);
|
|
370
|
+
exports.createProcessSchema = zod_1.z
|
|
371
|
+
.object({
|
|
372
|
+
label: nonEmptyString,
|
|
373
|
+
command: nonEmptyString,
|
|
374
|
+
cwd: nonEmptyString,
|
|
375
|
+
type: processTypeSchema,
|
|
376
|
+
})
|
|
377
|
+
.strict();
|
|
378
|
+
exports.updateProcessSchema = zod_1.z
|
|
379
|
+
.object({
|
|
380
|
+
label: nonEmptyString.optional(),
|
|
381
|
+
command: nonEmptyString.optional(),
|
|
382
|
+
cwd: nonEmptyString.optional(),
|
|
383
|
+
type: processTypeSchema.optional(),
|
|
384
|
+
})
|
|
385
|
+
.strict();
|
package/dist/server/index.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.startServer = startServer;
|
|
|
7
7
|
const node_child_process_1 = require("node:child_process");
|
|
8
8
|
const node_crypto_1 = require("node:crypto");
|
|
9
9
|
const promises_1 = require("node:fs/promises");
|
|
10
|
+
const node_net_1 = __importDefault(require("node:net"));
|
|
10
11
|
const node_os_1 = require("node:os");
|
|
11
12
|
const node_path_1 = require("node:path");
|
|
12
13
|
const node_util_1 = require("node:util");
|
|
@@ -146,7 +147,6 @@ function escapeHtml(str) {
|
|
|
146
147
|
const TASK_STATUS_LISTENERS = new Map();
|
|
147
148
|
const THREAD_UPDATE_LISTENERS = new Map();
|
|
148
149
|
const RUNTIME_SERVICES_BY_WORKSPACE = new Map();
|
|
149
|
-
const MAX_RUNTIME_SERVICE_LOG_ENTRIES = 2000;
|
|
150
150
|
function threadListenerKey(workspaceName, threadId) {
|
|
151
151
|
return `${workspaceName}:${threadId}`;
|
|
152
152
|
}
|
|
@@ -261,6 +261,16 @@ function createSSEStream(c, options) {
|
|
|
261
261
|
unsubscribe();
|
|
262
262
|
});
|
|
263
263
|
}
|
|
264
|
+
async function getRandomFreePort() {
|
|
265
|
+
return new Promise((resolve, reject) => {
|
|
266
|
+
const srv = node_net_1.default.createServer();
|
|
267
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
268
|
+
const port = srv.address().port;
|
|
269
|
+
srv.close(() => resolve(port));
|
|
270
|
+
});
|
|
271
|
+
srv.on("error", reject);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
264
274
|
function getWorkspaceServiceStore(workspaceName, createIfMissing = false) {
|
|
265
275
|
const existing = RUNTIME_SERVICES_BY_WORKSPACE.get(workspaceName);
|
|
266
276
|
if (existing) {
|
|
@@ -283,6 +293,7 @@ function toRuntimeServiceSummary(service) {
|
|
|
283
293
|
cwd: service.cwd,
|
|
284
294
|
status: service.status,
|
|
285
295
|
pid: service.pid,
|
|
296
|
+
port: service.port,
|
|
286
297
|
exit_code: service.exit_code,
|
|
287
298
|
started_at: service.started_at,
|
|
288
299
|
ended_at: service.ended_at,
|
|
@@ -321,9 +332,6 @@ function appendRuntimeServiceLog(service, stream, text) {
|
|
|
321
332
|
};
|
|
322
333
|
service.next_seq += 1;
|
|
323
334
|
service.logs.push(entry);
|
|
324
|
-
if (service.logs.length > MAX_RUNTIME_SERVICE_LOG_ENTRIES) {
|
|
325
|
-
service.logs.splice(0, service.logs.length - MAX_RUNTIME_SERVICE_LOG_ENTRIES);
|
|
326
|
-
}
|
|
327
335
|
publishRuntimeServiceEvent(service, { type: "log", entry });
|
|
328
336
|
}
|
|
329
337
|
function updateRuntimeServiceStatus(service, status, exitCode) {
|
|
@@ -334,6 +342,19 @@ function updateRuntimeServiceStatus(service, status, exitCode) {
|
|
|
334
342
|
type: "status",
|
|
335
343
|
service: toRuntimeServiceSummary(service),
|
|
336
344
|
});
|
|
345
|
+
if (service.process_db_id) {
|
|
346
|
+
void updateProcessDbStatus(service.workspace, service.process_db_id, status, exitCode);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
async function updateProcessDbStatus(workspaceName, processDbId, status, exitCode) {
|
|
350
|
+
try {
|
|
351
|
+
const db = await (0, db_1.openDatabase)(workspaceName);
|
|
352
|
+
const dbStatus = status === "ready" ? "running" : status;
|
|
353
|
+
await db.query(`UPDATE processes SET status = $1, exit_code = $2, stopped_at = CASE WHEN $1 IN ('stopped', 'exited') THEN CURRENT_TIMESTAMP ELSE stopped_at END WHERE id = $3`, [dbStatus, exitCode, processDbId]);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
// Best-effort DB update
|
|
357
|
+
}
|
|
337
358
|
}
|
|
338
359
|
function startRuntimeService(input) {
|
|
339
360
|
const serviceId = `svc_${(0, node_crypto_1.randomUUID)().slice(0, 12)}`;
|
|
@@ -347,6 +368,7 @@ function startRuntimeService(input) {
|
|
|
347
368
|
cwd: input.requestedCwd,
|
|
348
369
|
status: "running",
|
|
349
370
|
pid: null,
|
|
371
|
+
port: input.port ?? null,
|
|
350
372
|
exit_code: null,
|
|
351
373
|
started_at: startedAt,
|
|
352
374
|
ended_at: null,
|
|
@@ -472,9 +494,52 @@ async function recoverOrphanedTasks() {
|
|
|
472
494
|
}
|
|
473
495
|
}
|
|
474
496
|
}
|
|
497
|
+
async function recoverOrphanedProcesses() {
|
|
498
|
+
const workspaces = await (0, db_1.listWorkspaceNames)();
|
|
499
|
+
for (const workspaceName of workspaces) {
|
|
500
|
+
const db = await (0, db_1.openDatabase)(workspaceName);
|
|
501
|
+
const running = await db.query(`SELECT id, pid FROM processes WHERE status = 'running'`);
|
|
502
|
+
for (const row of running.rows) {
|
|
503
|
+
const pid = row.pid;
|
|
504
|
+
let alive = false;
|
|
505
|
+
if (pid && pid > 0) {
|
|
506
|
+
try {
|
|
507
|
+
process.kill(pid, 0);
|
|
508
|
+
alive = true;
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
alive = false;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (alive && pid) {
|
|
515
|
+
try {
|
|
516
|
+
process.kill(pid, "SIGTERM");
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
// already dead
|
|
520
|
+
}
|
|
521
|
+
// Give it a moment, then SIGKILL
|
|
522
|
+
setTimeout(() => {
|
|
523
|
+
try {
|
|
524
|
+
process.kill(pid, 0);
|
|
525
|
+
process.kill(pid, "SIGKILL");
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
// already dead
|
|
529
|
+
}
|
|
530
|
+
}, 3000);
|
|
531
|
+
}
|
|
532
|
+
await db.query(`UPDATE processes SET status = 'exited', stopped_at = CURRENT_TIMESTAMP, exit_code = -1 WHERE id = $1`, [row.id]);
|
|
533
|
+
}
|
|
534
|
+
if (running.rows.length > 0) {
|
|
535
|
+
console.log(`[recovery] ${workspaceName}: cleaned up ${running.rows.length} orphaned process(es)`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
475
539
|
async function startServer(options) {
|
|
476
540
|
await (0, db_1.setupPragma)();
|
|
477
541
|
await recoverOrphanedTasks();
|
|
542
|
+
await recoverOrphanedProcesses();
|
|
478
543
|
const apiUrl = process.env.PRAGMA_API_URL?.trim() || `http://127.0.0.1:${options.port}`;
|
|
479
544
|
const pragmaCliCommand = (0, pragmaCli_1.resolvePragmaCliCommand)(__dirname);
|
|
480
545
|
const executeRunner = new executeRunner_1.ExecuteRunner({
|
|
@@ -532,6 +597,8 @@ async function startServer(options) {
|
|
|
532
597
|
app.use("/code/*", middleware_1.workspaceMiddleware);
|
|
533
598
|
app.use("/context/*", middleware_1.workspaceMiddleware);
|
|
534
599
|
app.use("/context", middleware_1.workspaceMiddleware);
|
|
600
|
+
app.use("/workspace/outputs/*", middleware_1.workspaceMiddleware);
|
|
601
|
+
app.use("/workspace/outputs/files", middleware_1.workspaceMiddleware);
|
|
535
602
|
const turnRunner = new turnRunner_1.TurnRunner({
|
|
536
603
|
apiUrl,
|
|
537
604
|
pragmaCliCommand,
|
|
@@ -910,6 +977,155 @@ async function startServer(options) {
|
|
|
910
977
|
},
|
|
911
978
|
});
|
|
912
979
|
});
|
|
980
|
+
// ── Process Management ─────────────────────────────────────────
|
|
981
|
+
app.get("/processes", async (c) => {
|
|
982
|
+
const workspaceName = c.get("workspace");
|
|
983
|
+
const db = c.get("db");
|
|
984
|
+
const result = await db.query(`SELECT * FROM processes WHERE workspace = $1 ORDER BY created_at DESC`, [workspaceName]);
|
|
985
|
+
return c.json({ processes: result.rows });
|
|
986
|
+
});
|
|
987
|
+
app.get("/code/folders/:folderName/processes", async (c) => {
|
|
988
|
+
const workspaceName = c.get("workspace");
|
|
989
|
+
const folderName = c.req.param("folderName");
|
|
990
|
+
const db = c.get("db");
|
|
991
|
+
const result = await db.query(`SELECT * FROM processes WHERE workspace = $1 AND folder_name = $2 ORDER BY created_at DESC`, [workspaceName, folderName]);
|
|
992
|
+
return c.json({ processes: result.rows });
|
|
993
|
+
});
|
|
994
|
+
app.post("/code/folders/:folderName/processes", (0, validators_1.validateJson)(schemas_1.createProcessSchema), async (c) => {
|
|
995
|
+
const workspaceName = c.get("workspace");
|
|
996
|
+
const folderName = c.req.param("folderName");
|
|
997
|
+
const db = c.get("db");
|
|
998
|
+
const body = c.req.valid("json");
|
|
999
|
+
const processId = `proc_${(0, node_crypto_1.randomUUID)().slice(0, 12)}`;
|
|
1000
|
+
await db.query(`INSERT INTO processes (id, workspace, folder_name, label, command, cwd, type, status)
|
|
1001
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, 'stopped')`, [processId, workspaceName, folderName, body.label, body.command, body.cwd, body.type]);
|
|
1002
|
+
const result = await db.query(`SELECT * FROM processes WHERE id = $1`, [processId]);
|
|
1003
|
+
return c.json({ ok: true, process: result.rows[0] }, 201);
|
|
1004
|
+
});
|
|
1005
|
+
app.put("/processes/:processId", (0, validators_1.validateJson)(schemas_1.updateProcessSchema), async (c) => {
|
|
1006
|
+
const db = c.get("db");
|
|
1007
|
+
const processId = c.req.param("processId");
|
|
1008
|
+
const body = c.req.valid("json");
|
|
1009
|
+
const existing = await db.query(`SELECT status FROM processes WHERE id = $1`, [processId]);
|
|
1010
|
+
if (existing.rows.length === 0) {
|
|
1011
|
+
throw new db_1.PragmaError("PROCESS_NOT_FOUND", 404, `Process not found: ${processId}`);
|
|
1012
|
+
}
|
|
1013
|
+
if (existing.rows[0].status === "running") {
|
|
1014
|
+
throw new db_1.PragmaError("PROCESS_RUNNING", 400, "Cannot update a running process.");
|
|
1015
|
+
}
|
|
1016
|
+
const updates = [];
|
|
1017
|
+
const params = [];
|
|
1018
|
+
let paramIndex = 1;
|
|
1019
|
+
if (body.label !== undefined) {
|
|
1020
|
+
updates.push(`label = $${paramIndex++}`);
|
|
1021
|
+
params.push(body.label);
|
|
1022
|
+
}
|
|
1023
|
+
if (body.command !== undefined) {
|
|
1024
|
+
updates.push(`command = $${paramIndex++}`);
|
|
1025
|
+
params.push(body.command);
|
|
1026
|
+
}
|
|
1027
|
+
if (body.cwd !== undefined) {
|
|
1028
|
+
updates.push(`cwd = $${paramIndex++}`);
|
|
1029
|
+
params.push(body.cwd);
|
|
1030
|
+
}
|
|
1031
|
+
if (body.type !== undefined) {
|
|
1032
|
+
updates.push(`type = $${paramIndex++}`);
|
|
1033
|
+
params.push(body.type);
|
|
1034
|
+
}
|
|
1035
|
+
if (updates.length === 0) {
|
|
1036
|
+
const result = await db.query(`SELECT * FROM processes WHERE id = $1`, [processId]);
|
|
1037
|
+
return c.json({ ok: true, process: result.rows[0] });
|
|
1038
|
+
}
|
|
1039
|
+
params.push(processId);
|
|
1040
|
+
await db.query(`UPDATE processes SET ${updates.join(", ")} WHERE id = $${paramIndex}`, params);
|
|
1041
|
+
const result = await db.query(`SELECT * FROM processes WHERE id = $1`, [processId]);
|
|
1042
|
+
return c.json({ ok: true, process: result.rows[0] });
|
|
1043
|
+
});
|
|
1044
|
+
app.delete("/processes/:processId", async (c) => {
|
|
1045
|
+
const workspaceName = c.get("workspace");
|
|
1046
|
+
const db = c.get("db");
|
|
1047
|
+
const processId = c.req.param("processId");
|
|
1048
|
+
const existing = await db.query(`SELECT status FROM processes WHERE id = $1`, [processId]);
|
|
1049
|
+
if (existing.rows.length === 0) {
|
|
1050
|
+
throw new db_1.PragmaError("PROCESS_NOT_FOUND", 404, `Process not found: ${processId}`);
|
|
1051
|
+
}
|
|
1052
|
+
// Stop if running
|
|
1053
|
+
if (existing.rows[0].status === "running") {
|
|
1054
|
+
const store = getWorkspaceServiceStore(workspaceName);
|
|
1055
|
+
for (const service of store.values()) {
|
|
1056
|
+
if (service.process_db_id === processId) {
|
|
1057
|
+
stopRuntimeService(service);
|
|
1058
|
+
break;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
await db.query(`DELETE FROM processes WHERE id = $1`, [processId]);
|
|
1063
|
+
return c.json({ ok: true });
|
|
1064
|
+
});
|
|
1065
|
+
app.post("/processes/:processId/start", async (c) => {
|
|
1066
|
+
const workspaceName = c.get("workspace");
|
|
1067
|
+
const db = c.get("db");
|
|
1068
|
+
const processId = c.req.param("processId");
|
|
1069
|
+
const workspacePaths = (0, db_1.getWorkspacePaths)(workspaceName);
|
|
1070
|
+
const result = await db.query(`SELECT * FROM processes WHERE id = $1`, [processId]);
|
|
1071
|
+
if (result.rows.length === 0) {
|
|
1072
|
+
throw new db_1.PragmaError("PROCESS_NOT_FOUND", 404, `Process not found: ${processId}`);
|
|
1073
|
+
}
|
|
1074
|
+
const proc = result.rows[0];
|
|
1075
|
+
if (proc.status === "running") {
|
|
1076
|
+
throw new db_1.PragmaError("PROCESS_ALREADY_RUNNING", 400, "Process is already running.");
|
|
1077
|
+
}
|
|
1078
|
+
const absoluteCwd = (0, node_path_1.join)(workspacePaths.codeDir, proc.folder_name, proc.cwd === "." ? "" : proc.cwd);
|
|
1079
|
+
const cwdInfo = await (0, promises_1.stat)(absoluteCwd).catch(() => null);
|
|
1080
|
+
if (!cwdInfo?.isDirectory()) {
|
|
1081
|
+
throw new db_1.PragmaError("PROCESS_CWD_NOT_FOUND", 400, `Working directory not found: ${absoluteCwd}`);
|
|
1082
|
+
}
|
|
1083
|
+
const service = startRuntimeService({
|
|
1084
|
+
workspaceName,
|
|
1085
|
+
taskId: "",
|
|
1086
|
+
label: proc.label,
|
|
1087
|
+
command: proc.command,
|
|
1088
|
+
requestedCwd: proc.cwd,
|
|
1089
|
+
absoluteCwd,
|
|
1090
|
+
env: { ...process.env, PRAGMA_WORKSPACE_NAME: workspaceName },
|
|
1091
|
+
});
|
|
1092
|
+
service.process_db_id = processId;
|
|
1093
|
+
await db.query(`UPDATE processes SET status = 'running', pid = $1, started_at = CURRENT_TIMESTAMP, stopped_at = NULL, exit_code = NULL WHERE id = $2`, [service.pid, processId]);
|
|
1094
|
+
return c.json({ ok: true, service: toRuntimeServiceSummary(service) });
|
|
1095
|
+
});
|
|
1096
|
+
app.post("/processes/:processId/stop", async (c) => {
|
|
1097
|
+
const workspaceName = c.get("workspace");
|
|
1098
|
+
const db = c.get("db");
|
|
1099
|
+
const processId = c.req.param("processId");
|
|
1100
|
+
const result = await db.query(`SELECT id, status FROM processes WHERE id = $1`, [processId]);
|
|
1101
|
+
if (result.rows.length === 0) {
|
|
1102
|
+
throw new db_1.PragmaError("PROCESS_NOT_FOUND", 404, `Process not found: ${processId}`);
|
|
1103
|
+
}
|
|
1104
|
+
const store = getWorkspaceServiceStore(workspaceName);
|
|
1105
|
+
for (const service of store.values()) {
|
|
1106
|
+
if (service.process_db_id === processId) {
|
|
1107
|
+
stopRuntimeService(service);
|
|
1108
|
+
return c.json({ ok: true, service: toRuntimeServiceSummary(service) });
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
// Not in memory — just update DB
|
|
1112
|
+
await db.query(`UPDATE processes SET status = 'stopped', stopped_at = CURRENT_TIMESTAMP WHERE id = $1`, [processId]);
|
|
1113
|
+
return c.json({ ok: true });
|
|
1114
|
+
});
|
|
1115
|
+
app.post("/code/folders/:folderName/processes/detect", async (c) => {
|
|
1116
|
+
const workspaceName = c.get("workspace");
|
|
1117
|
+
const folderName = c.req.param("folderName");
|
|
1118
|
+
const db = c.get("db");
|
|
1119
|
+
const workspacePaths = (0, db_1.getWorkspacePaths)(workspaceName);
|
|
1120
|
+
const folderPath = (0, node_path_1.join)(workspacePaths.codeDir, folderName);
|
|
1121
|
+
const folderInfo = await (0, promises_1.stat)(folderPath).catch(() => null);
|
|
1122
|
+
if (!folderInfo?.isDirectory()) {
|
|
1123
|
+
throw new db_1.PragmaError("CODE_FOLDER_NOT_FOUND", 404, `Code folder not found: ${folderName}`);
|
|
1124
|
+
}
|
|
1125
|
+
// Run detection async
|
|
1126
|
+
void detectProcessCommands(workspaceName, folderName, workspacePaths, db);
|
|
1127
|
+
return c.json({ ok: true, detecting: true });
|
|
1128
|
+
});
|
|
913
1129
|
// Read-only SSE event stream for a conversation thread.
|
|
914
1130
|
// Replays missed events using Last-Event-ID (the seq number),
|
|
915
1131
|
// then tails new events via the in-memory pub/sub.
|
|
@@ -1856,22 +2072,38 @@ LIMIT 1
|
|
|
1856
2072
|
const processCwd = proc.cwd
|
|
1857
2073
|
? await resolveTaskCommandCwd(runRoot, proc.cwd)
|
|
1858
2074
|
: runRoot;
|
|
2075
|
+
const port = await getRandomFreePort();
|
|
2076
|
+
const env = {
|
|
2077
|
+
...process.env,
|
|
2078
|
+
PORT: String(port),
|
|
2079
|
+
PRAGMA_WORKSPACE_NAME: workspaceName,
|
|
2080
|
+
PRAGMA_TASK_ID: taskId,
|
|
2081
|
+
};
|
|
2082
|
+
// Rewrite command for frameworks that don't read PORT
|
|
2083
|
+
let command = proc.command;
|
|
2084
|
+
if (/\bvite\b|webpack-dev-server/.test(command)) {
|
|
2085
|
+
command += ` --port ${port}`;
|
|
2086
|
+
}
|
|
2087
|
+
if (/\bvite\b|\bnext\b/.test(command)) {
|
|
2088
|
+
command += ` --host 127.0.0.1`;
|
|
2089
|
+
}
|
|
1859
2090
|
const service = startRuntimeService({
|
|
1860
2091
|
workspaceName,
|
|
1861
2092
|
taskId,
|
|
1862
2093
|
label: proc.name,
|
|
1863
|
-
command
|
|
2094
|
+
command,
|
|
1864
2095
|
requestedCwd: proc.cwd || ".",
|
|
1865
2096
|
absoluteCwd: processCwd,
|
|
1866
|
-
env
|
|
1867
|
-
...process.env,
|
|
1868
|
-
PRAGMA_WORKSPACE_NAME: workspaceName,
|
|
1869
|
-
PRAGMA_TASK_ID: taskId,
|
|
1870
|
-
},
|
|
2097
|
+
env,
|
|
1871
2098
|
readyPattern: proc.ready_pattern,
|
|
1872
|
-
port
|
|
2099
|
+
port,
|
|
1873
2100
|
healthcheck: proc.healthcheck,
|
|
1874
2101
|
});
|
|
2102
|
+
// Track task-level processes in the processes table
|
|
2103
|
+
const taskProcessId = `proc_${(0, node_crypto_1.randomUUID)().slice(0, 12)}`;
|
|
2104
|
+
service.process_db_id = taskProcessId;
|
|
2105
|
+
void db.query(`INSERT INTO processes (id, workspace, folder_name, label, command, cwd, type, status, pid, task_id, started_at)
|
|
2106
|
+
VALUES ($1, $2, $3, $4, $5, $6, 'service', 'running', $7, $8, CURRENT_TIMESTAMP)`, [taskProcessId, workspaceName, "", proc.name, command, proc.cwd || ".", service.pid, taskId]);
|
|
1875
2107
|
services[proc.name] = toRuntimeServiceSummary(service);
|
|
1876
2108
|
}
|
|
1877
2109
|
return c.json({ ok: true, services });
|
|
@@ -1879,35 +2111,33 @@ LIMIT 1
|
|
|
1879
2111
|
app.post("/tasks/:taskId/testing/stop", async (c) => {
|
|
1880
2112
|
const workspaceName = c.get("workspace");
|
|
1881
2113
|
const taskId = c.req.param("taskId");
|
|
2114
|
+
const db = c.get("db");
|
|
1882
2115
|
const store = getWorkspaceServiceStore(workspaceName);
|
|
1883
2116
|
for (const service of store.values()) {
|
|
1884
2117
|
if (service.task_id === taskId) {
|
|
1885
2118
|
stopRuntimeService(service);
|
|
1886
2119
|
}
|
|
1887
2120
|
}
|
|
2121
|
+
// Update task processes in DB
|
|
2122
|
+
void db.query(`UPDATE processes SET status = 'stopped', stopped_at = CURRENT_TIMESTAMP WHERE task_id = $1 AND status = 'running'`, [taskId]);
|
|
1888
2123
|
return c.json({ ok: true });
|
|
1889
2124
|
});
|
|
1890
2125
|
app.post("/tasks/:taskId/testing/proxy", (0, validators_1.validateJson)(schemas_1.testingProxyRequestSchema), async (c) => {
|
|
2126
|
+
const workspaceName = c.get("workspace");
|
|
1891
2127
|
const taskId = c.req.param("taskId");
|
|
1892
2128
|
const body = c.req.valid("json");
|
|
1893
|
-
|
|
1894
|
-
const
|
|
1895
|
-
const
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
try {
|
|
1901
|
-
config = JSON.parse(row.testing_config_json);
|
|
1902
|
-
}
|
|
1903
|
-
catch {
|
|
1904
|
-
throw new db_1.PragmaError("INVALID_TESTING_CONFIG", 400, "Testing config JSON is invalid.");
|
|
2129
|
+
let servicePort = null;
|
|
2130
|
+
const store = getWorkspaceServiceStore(workspaceName);
|
|
2131
|
+
for (const service of store.values()) {
|
|
2132
|
+
if (service.task_id === taskId && service.label === body.process_name && service.port) {
|
|
2133
|
+
servicePort = service.port;
|
|
2134
|
+
break;
|
|
2135
|
+
}
|
|
1905
2136
|
}
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
throw new db_1.PragmaError("PROCESS_NOT_FOUND", 404, `Process "${body.process_name}" not found or has no port configured.`);
|
|
2137
|
+
if (!servicePort) {
|
|
2138
|
+
throw new db_1.PragmaError("PROCESS_NOT_FOUND", 404, `Process "${body.process_name}" not found or has no port assigned.`);
|
|
1909
2139
|
}
|
|
1910
|
-
const targetUrl = `http://
|
|
2140
|
+
const targetUrl = `http://127.0.0.1:${servicePort}${body.path}`;
|
|
1911
2141
|
const startTime = Date.now();
|
|
1912
2142
|
try {
|
|
1913
2143
|
const fetchResponse = await fetch(targetUrl, {
|
|
@@ -2972,6 +3202,9 @@ VALUES ($1, $2, 'queued', $3, NULL, NULL, $4)
|
|
|
2972
3202
|
throw new db_1.PragmaError("CLONE_CODE_REPO_FAILED", 400, errorMessage(error));
|
|
2973
3203
|
}
|
|
2974
3204
|
const folders = await listCodeFolders(paths.codeDir);
|
|
3205
|
+
// Trigger process detection in the background
|
|
3206
|
+
const db = c.get("db");
|
|
3207
|
+
void detectProcessCommands(workspaceName, folderName, paths, db);
|
|
2975
3208
|
return c.json({ ok: true, folder: { name: folderName }, folders }, 201);
|
|
2976
3209
|
});
|
|
2977
3210
|
app.post("/code/folders/pick-local", async (c) => {
|
|
@@ -3032,6 +3265,9 @@ VALUES ($1, $2, 'queued', $3, NULL, NULL, $4)
|
|
|
3032
3265
|
throw new db_1.PragmaError("COPY_LOCAL_CODE_FAILED", 400, errorMessage(error));
|
|
3033
3266
|
}
|
|
3034
3267
|
const folders = await listCodeFolders(paths.codeDir);
|
|
3268
|
+
// Trigger process detection in the background
|
|
3269
|
+
const db = c.get("db");
|
|
3270
|
+
void detectProcessCommands(workspaceName, folderName, paths, db);
|
|
3035
3271
|
return c.json({ ok: true, folder: { name: folderName }, folders }, 201);
|
|
3036
3272
|
});
|
|
3037
3273
|
app.post("/code/folders/import", async (c) => {
|
|
@@ -4020,6 +4256,20 @@ VALUES ($1, $2, 'queued', $3, NULL, NULL, $4)
|
|
|
4020
4256
|
console.log(`Pragma API listening on http://127.0.0.1:${info.port}`);
|
|
4021
4257
|
});
|
|
4022
4258
|
const shutdown = () => {
|
|
4259
|
+
for (const [, store] of RUNTIME_SERVICES_BY_WORKSPACE) {
|
|
4260
|
+
for (const service of store.values()) {
|
|
4261
|
+
if (service.status === "running" || service.status === "ready") {
|
|
4262
|
+
try {
|
|
4263
|
+
if (service.pid && service.pid > 0) {
|
|
4264
|
+
process.kill(service.pid, "SIGTERM");
|
|
4265
|
+
}
|
|
4266
|
+
}
|
|
4267
|
+
catch {
|
|
4268
|
+
// Process may already be dead
|
|
4269
|
+
}
|
|
4270
|
+
}
|
|
4271
|
+
}
|
|
4272
|
+
}
|
|
4023
4273
|
server.close();
|
|
4024
4274
|
void (0, db_1.closeOpenDatabases)().finally(() => {
|
|
4025
4275
|
process.exit(0);
|
|
@@ -4028,6 +4278,85 @@ VALUES ($1, $2, 'queued', $3, NULL, NULL, $4)
|
|
|
4028
4278
|
process.once("SIGINT", shutdown);
|
|
4029
4279
|
process.once("SIGTERM", shutdown);
|
|
4030
4280
|
}
|
|
4281
|
+
async function detectProcessCommands(workspaceName, folderName, workspacePaths, db) {
|
|
4282
|
+
try {
|
|
4283
|
+
const folderPath = (0, node_path_1.join)(workspacePaths.codeDir, folderName);
|
|
4284
|
+
// Read key files to detect commands
|
|
4285
|
+
const detectedProcesses = [];
|
|
4286
|
+
// Check for package.json
|
|
4287
|
+
try {
|
|
4288
|
+
const pkgJson = JSON.parse(await (0, promises_1.readFile)((0, node_path_1.join)(folderPath, "package.json"), "utf8"));
|
|
4289
|
+
const scripts = pkgJson.scripts || {};
|
|
4290
|
+
for (const [name, cmd] of Object.entries(scripts)) {
|
|
4291
|
+
if (typeof cmd !== "string")
|
|
4292
|
+
continue;
|
|
4293
|
+
if (name === "dev" || name === "start" || name === "serve") {
|
|
4294
|
+
detectedProcesses.push({ label: `${name} (npm)`, command: `npm run ${name}`, cwd: ".", type: "service" });
|
|
4295
|
+
}
|
|
4296
|
+
else if (name === "build" || name === "test" || name === "lint") {
|
|
4297
|
+
detectedProcesses.push({ label: `${name} (npm)`, command: `npm run ${name}`, cwd: ".", type: "script" });
|
|
4298
|
+
}
|
|
4299
|
+
}
|
|
4300
|
+
}
|
|
4301
|
+
catch { /* no package.json */ }
|
|
4302
|
+
// Check for Makefile
|
|
4303
|
+
try {
|
|
4304
|
+
const makefile = await (0, promises_1.readFile)((0, node_path_1.join)(folderPath, "Makefile"), "utf8");
|
|
4305
|
+
const targets = makefile.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):/gm);
|
|
4306
|
+
if (targets) {
|
|
4307
|
+
for (const target of targets.slice(0, 5)) {
|
|
4308
|
+
const name = target.replace(":", "");
|
|
4309
|
+
if (["run", "serve", "dev", "start"].includes(name)) {
|
|
4310
|
+
detectedProcesses.push({ label: `make ${name}`, command: `make ${name}`, cwd: ".", type: "service" });
|
|
4311
|
+
}
|
|
4312
|
+
else if (["build", "test", "lint", "clean"].includes(name)) {
|
|
4313
|
+
detectedProcesses.push({ label: `make ${name}`, command: `make ${name}`, cwd: ".", type: "script" });
|
|
4314
|
+
}
|
|
4315
|
+
}
|
|
4316
|
+
}
|
|
4317
|
+
}
|
|
4318
|
+
catch { /* no Makefile */ }
|
|
4319
|
+
// Check for pyproject.toml
|
|
4320
|
+
try {
|
|
4321
|
+
await (0, promises_1.stat)((0, node_path_1.join)(folderPath, "pyproject.toml"));
|
|
4322
|
+
detectedProcesses.push({ label: "Python Dev", command: "python -m uvicorn main:app --reload", cwd: ".", type: "service" });
|
|
4323
|
+
}
|
|
4324
|
+
catch { /* no pyproject.toml */ }
|
|
4325
|
+
// Check for Cargo.toml
|
|
4326
|
+
try {
|
|
4327
|
+
await (0, promises_1.stat)((0, node_path_1.join)(folderPath, "Cargo.toml"));
|
|
4328
|
+
detectedProcesses.push({ label: "Cargo Run", command: "cargo run", cwd: ".", type: "service" });
|
|
4329
|
+
detectedProcesses.push({ label: "Cargo Build", command: "cargo build", cwd: ".", type: "script" });
|
|
4330
|
+
}
|
|
4331
|
+
catch { /* no Cargo.toml */ }
|
|
4332
|
+
// Check for docker-compose
|
|
4333
|
+
for (const dcFile of ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]) {
|
|
4334
|
+
try {
|
|
4335
|
+
await (0, promises_1.stat)((0, node_path_1.join)(folderPath, dcFile));
|
|
4336
|
+
detectedProcesses.push({ label: "Docker Compose Up", command: "docker compose up", cwd: ".", type: "service" });
|
|
4337
|
+
break;
|
|
4338
|
+
}
|
|
4339
|
+
catch { /* no docker-compose */ }
|
|
4340
|
+
}
|
|
4341
|
+
if (detectedProcesses.length === 0)
|
|
4342
|
+
return;
|
|
4343
|
+
// Deduplicate: skip processes that already exist for this folder
|
|
4344
|
+
const existing = await db.query(`SELECT command, cwd FROM processes WHERE workspace = $1 AND folder_name = $2`, [workspaceName, folderName]);
|
|
4345
|
+
const existingKeys = new Set(existing.rows.map((r) => `${r.command}::${r.cwd}`));
|
|
4346
|
+
for (const proc of detectedProcesses) {
|
|
4347
|
+
const key = `${proc.command}::${proc.cwd}`;
|
|
4348
|
+
if (existingKeys.has(key))
|
|
4349
|
+
continue;
|
|
4350
|
+
existingKeys.add(key);
|
|
4351
|
+
const processId = `proc_${(0, node_crypto_1.randomUUID)().slice(0, 12)}`;
|
|
4352
|
+
await db.query(`INSERT INTO processes (id, workspace, folder_name, label, command, cwd, type, status)
|
|
4353
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, 'stopped')`, [processId, workspaceName, folderName, proc.label, proc.command, proc.cwd, proc.type]);
|
|
4354
|
+
}
|
|
4355
|
+
}
|
|
4356
|
+
catch (error) {
|
|
4357
|
+
console.error(`[detect] Failed to detect processes for ${folderName}:`, error);
|
|
4358
|
+
}
|
|
4359
|
+
}
|
|
4031
4360
|
async function isDirectoryEmpty(path) {
|
|
4032
4361
|
try {
|
|
4033
4362
|
const entries = await (0, promises_1.readdir)(path);
|