@splinterzzz/ouro 0.1.3 → 0.1.5
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/dashboard-dist/assets/index-D-niaPon.js +64 -0
- package/dashboard-dist/index.html +1 -1
- package/package.json +1 -1
- package/src/lib/claudeCodeExec.js +4 -1
- package/src/lib/codexExec.js +3 -1
- package/src/lib/config.js +9 -0
- package/src/lib/daemon.js +16 -1
- package/src/server/index.js +136 -76
- package/dashboard-dist/assets/index-DERJjla9.js +0 -64
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
flash white around a near-black app on load. -->
|
|
10
10
|
<meta name="theme-color" content="#0b0a12" />
|
|
11
11
|
<title>ouro</title>
|
|
12
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
+
<script type="module" crossorigin src="/assets/index-D-niaPon.js"></script>
|
|
13
13
|
<link rel="stylesheet" crossorigin href="/assets/index-C21ZXjPS.css">
|
|
14
14
|
</head>
|
|
15
15
|
<body>
|
package/package.json
CHANGED
|
@@ -55,7 +55,10 @@ function runClaude(args, { cwd, onEvent, signal } = {}) {
|
|
|
55
55
|
return new Promise((resolve, reject) => {
|
|
56
56
|
// `signal` wires cancellation straight to the child: aborting SIGTERMs the
|
|
57
57
|
// CLI rather than leaving it running detached. See lib/runs.js.
|
|
58
|
-
|
|
58
|
+
// windowsHide: the dashboard daemon spawns this per run — without it, each
|
|
59
|
+
// run flashes an empty `claude` console window on Windows. Output is piped
|
|
60
|
+
// and parsed here, so the child never needs a visible console of its own.
|
|
61
|
+
const proc = spawn(CLAUDE_BIN, args, { cwd, env: process.env, signal, windowsHide: true });
|
|
59
62
|
|
|
60
63
|
const rl = readline.createInterface({ input: proc.stdout });
|
|
61
64
|
let sessionId = null;
|
package/src/lib/codexExec.js
CHANGED
|
@@ -63,7 +63,9 @@ function agentFlags(agent) {
|
|
|
63
63
|
function runCodexExec(args, { cwd, onEvent, signal } = {}) {
|
|
64
64
|
return new Promise((resolve, reject) => {
|
|
65
65
|
// `signal` makes cancellation kill the child process. See lib/runs.js.
|
|
66
|
-
|
|
66
|
+
// windowsHide: keep the per-run child from flashing an empty console window
|
|
67
|
+
// on Windows — output is piped and parsed here, not shown in a console.
|
|
68
|
+
const proc = spawn(CODEX_BIN, args, { cwd, env: process.env, signal, windowsHide: true });
|
|
67
69
|
|
|
68
70
|
const rl = readline.createInterface({ input: proc.stdout });
|
|
69
71
|
let sessionId = null;
|
package/src/lib/config.js
CHANGED
|
@@ -16,6 +16,10 @@ const DEFAULTS = {
|
|
|
16
16
|
// explicit "Create PR" button instead — pushing is outward-facing, so this
|
|
17
17
|
// is deliberately a knob and not a hardcode.
|
|
18
18
|
autoShip: true,
|
|
19
|
+
// Ceiling on agent-loop QA loop-back re-runs (enterStaging) before the ticket
|
|
20
|
+
// escalates to a human — a safety valve against a runaway loop that keeps
|
|
21
|
+
// failing QA and re-running the (expensive) engineer forever.
|
|
22
|
+
maxQaAttempts: 3,
|
|
19
23
|
telegram: {
|
|
20
24
|
botTokenEnvVar: "OURO_TELEGRAM_BOT_TOKEN",
|
|
21
25
|
chatIdEnvVar: "OURO_TELEGRAM_CHAT_ID",
|
|
@@ -94,6 +98,11 @@ export function getAutoShip() {
|
|
|
94
98
|
return readConfig().autoShip !== false;
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
export function getMaxQaAttempts() {
|
|
102
|
+
const n = readConfig().maxQaAttempts;
|
|
103
|
+
return Number.isInteger(n) && n >= 1 ? n : 3;
|
|
104
|
+
}
|
|
105
|
+
|
|
97
106
|
/** Staging config, each field null when unset (ouro then resolves it per-repo). */
|
|
98
107
|
export function getStaging() {
|
|
99
108
|
const s = readConfig().staging ?? {};
|
package/src/lib/daemon.js
CHANGED
|
@@ -60,9 +60,24 @@ export function updateRecord(name, patch) {
|
|
|
60
60
|
return next;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Signal 0 probes for existence without delivering anything. On win32 this is
|
|
65
|
+
* unreliable on its own: PIDs recycle fast, and once our dead pid is reused by
|
|
66
|
+
* an unrelated process owned by another user/session, `process.kill(pid, 0)`
|
|
67
|
+
* throws EPERM (exists, just not ours) — indistinguishable from our own
|
|
68
|
+
* still-alive service. That false positive pins a stale pid file forever
|
|
69
|
+
* (`stop` reports "refused to die" against a ghost). Cross-check with
|
|
70
|
+
* `tasklist` so a recycled pid isn't mistaken for our service.
|
|
71
|
+
*/
|
|
64
72
|
export function isAlive(pid) {
|
|
65
73
|
if (!pid) return false;
|
|
74
|
+
if (process.platform === "win32") {
|
|
75
|
+
const result = spawnSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
|
|
76
|
+
encoding: "utf-8",
|
|
77
|
+
});
|
|
78
|
+
const out = result.stdout || "";
|
|
79
|
+
return out.includes(`"${pid}"`);
|
|
80
|
+
}
|
|
66
81
|
try {
|
|
67
82
|
process.kill(pid, 0);
|
|
68
83
|
return true;
|
package/src/server/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { contextManifest, listContextFiles, listReferencedFiles } from "../lib/a
|
|
|
10
10
|
import { appendRunLog, readRunLog } from "../lib/ouroLog.js";
|
|
11
11
|
import { runTests } from "../lib/staging.js";
|
|
12
12
|
import { startPreview, stopPreview, previewInfo } from "../lib/preview.js";
|
|
13
|
-
import { readConfig, writeConfig, getDefaultMode, setDefaultMode, getAutoShip, telegramTokenVar } from "../lib/config.js";
|
|
13
|
+
import { readConfig, writeConfig, getDefaultMode, setDefaultMode, getAutoShip, getMaxQaAttempts, telegramTokenVar } from "../lib/config.js";
|
|
14
14
|
import { readEnvVars, writeEnvVars } from "../lib/env.js";
|
|
15
15
|
import { looksLikeToken, maskToken, verifyBotToken } from "../lib/telegram.js";
|
|
16
16
|
import { startService, stopService, serviceStatus, isAlive, tailLog, uptime } from "../lib/daemon.js";
|
|
@@ -103,7 +103,7 @@ export function qaPrompt(ticket, testResult, preview) {
|
|
|
103
103
|
"3. If it is not a UI change, tests-only is fine.",
|
|
104
104
|
"",
|
|
105
105
|
"Judge the running result against the acceptance criteria and the test results. Never call something ready just because it was asked for.",
|
|
106
|
-
'Respond with ONLY a JSON object, no prose and no fences: {"ready": boolean, "summary": string, "reasons": string[], "ui_change": boolean, "visual_method": "screenshot"|"html"|"none", "questions": string[]}. When not ready, `reasons` must be concrete and actionable. `questions` are
|
|
106
|
+
'Respond with ONLY a JSON object, no prose and no fences: {"ready": boolean, "summary": string, "reasons": string[], "ui_change": boolean, "visual_method": "screenshot"|"html"|"none", "questions": string[]}. When not ready, `reasons` must be concrete and actionable. `questions` are open items to resolve before shipping — in an agent loop the engineer answers them itself, in human-in-loop the person does.'
|
|
107
107
|
);
|
|
108
108
|
return parts.join("\n");
|
|
109
109
|
}
|
|
@@ -139,10 +139,25 @@ export function qaSummaryLine(v) {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
export function qaFeedbackBlock(v) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
.
|
|
145
|
-
|
|
142
|
+
const blocks = [];
|
|
143
|
+
if (v?.reasons?.length) {
|
|
144
|
+
blocks.push(
|
|
145
|
+
`The QA gate sent this back. Address every point before it can ship:\n${v.reasons
|
|
146
|
+
.map((r, i) => `${i + 1}. ${r}`)
|
|
147
|
+
.join("\n")}`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
// Agent loop: no human will answer QA's questions, so the engineer resolves
|
|
151
|
+
// them itself. Only reached on the agent-loop loop-back path (enterStaging),
|
|
152
|
+
// so framing them as "yours to answer" is always accurate here.
|
|
153
|
+
if (v?.questions?.length) {
|
|
154
|
+
blocks.push(
|
|
155
|
+
`QA raised these open questions. This is an agent loop — no human will answer them, so resolve each one yourself: investigate the code, decide, act on it, and state the answer in your final message:\n${v.questions
|
|
156
|
+
.map((q, i) => `${i + 1}. ${q}`)
|
|
157
|
+
.join("\n")}`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return blocks.length ? `\n\n${blocks.join("\n\n")}` : "";
|
|
146
161
|
}
|
|
147
162
|
|
|
148
163
|
/**
|
|
@@ -339,9 +354,10 @@ export function createServer() {
|
|
|
339
354
|
appendRunLog(store.get(ticketId), shipOutcome(shipResult));
|
|
340
355
|
return;
|
|
341
356
|
}
|
|
342
|
-
// Not ready. Loop-stop guard:
|
|
343
|
-
// regardless of mode —
|
|
344
|
-
|
|
357
|
+
// Not ready. Loop-stop guard: once we hit the configurable ceiling
|
|
358
|
+
// (maxQaAttempts) the ticket escalates to a human, regardless of mode —
|
|
359
|
+
// don't loop forever re-running the expensive engineer.
|
|
360
|
+
if (attempt >= getMaxQaAttempts()) {
|
|
345
361
|
store.update(ticketId, { awaitingQa: true, escalated: true });
|
|
346
362
|
appendRunLog(store.get(ticketId), `QA failed ${attempt}× — escalated to human`);
|
|
347
363
|
return;
|
|
@@ -361,36 +377,29 @@ export function createServer() {
|
|
|
361
377
|
}
|
|
362
378
|
}
|
|
363
379
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
app.post("/api/tickets/:id/analyze", async (req, res) => {
|
|
377
|
-
const ticket = store.get(req.params.id);
|
|
378
|
-
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
379
|
-
if (runs.isRunning(ticket.id)) return res.status(409).json({ error: "already running" });
|
|
380
|
+
/**
|
|
381
|
+
* The read-only Analyst pass, factored out of its route so the auto-pipeline
|
|
382
|
+
* can chain it into a run without a second HTTP hop. Owns its own run
|
|
383
|
+
* registration and the transient `analyzing` flag, and returns `{ ok }` so a
|
|
384
|
+
* caller can decide whether to proceed — abort or a caught error is `ok:false`
|
|
385
|
+
* (the error is still logged to the ticket, as when a human clicked Analyze).
|
|
386
|
+
*/
|
|
387
|
+
async function runAnalyze(ticketId) {
|
|
388
|
+
const ticket = store.get(ticketId);
|
|
389
|
+
if (!ticket || runs.isRunning(ticketId)) return { ok: false };
|
|
380
390
|
|
|
381
391
|
let signal;
|
|
382
392
|
try {
|
|
383
|
-
signal = runs.begin(
|
|
384
|
-
} catch
|
|
385
|
-
return
|
|
393
|
+
signal = runs.begin(ticketId, "analyze");
|
|
394
|
+
} catch {
|
|
395
|
+
return { ok: false };
|
|
386
396
|
}
|
|
387
397
|
|
|
388
|
-
res.json({ started: true });
|
|
389
398
|
// Analyze is read-only and leaves the ticket where it is; a transient flag
|
|
390
399
|
// drives the card's "Analyzing…" state without faking a worktree run.
|
|
391
|
-
store.update(
|
|
400
|
+
store.update(ticketId, { analyzing: true });
|
|
392
401
|
|
|
393
|
-
const onEvent = (event) => store.appendLog(
|
|
402
|
+
const onEvent = (event) => store.appendLog(ticketId, { type: "agent_event", event });
|
|
394
403
|
|
|
395
404
|
try {
|
|
396
405
|
// Always the Analyst agent for this step, regardless of the ticket's
|
|
@@ -406,8 +415,8 @@ export function createServer() {
|
|
|
406
415
|
onEvent,
|
|
407
416
|
agent: analyst,
|
|
408
417
|
});
|
|
409
|
-
if (signal.aborted) return; // cancelled mid-analysis — /cancel already reconciled it
|
|
410
|
-
store.update(
|
|
418
|
+
if (signal.aborted) return { ok: false }; // cancelled mid-analysis — /cancel already reconciled it
|
|
419
|
+
store.update(ticketId, {
|
|
411
420
|
status: "analyzed",
|
|
412
421
|
analyzing: false,
|
|
413
422
|
summary: result.summary,
|
|
@@ -415,51 +424,39 @@ export function createServer() {
|
|
|
415
424
|
filesLikelyAffected: Array.isArray(result.files_likely_affected) ? result.files_likely_affected : [],
|
|
416
425
|
acceptanceCriteria: Array.isArray(result.acceptance_criteria) ? result.acceptance_criteria : [],
|
|
417
426
|
});
|
|
427
|
+
return { ok: true };
|
|
418
428
|
} catch (err) {
|
|
419
|
-
store.update(
|
|
420
|
-
store.appendLog(
|
|
429
|
+
store.update(ticketId, { analyzing: false });
|
|
430
|
+
store.appendLog(ticketId, { type: "error", text: String(err.message || err) });
|
|
431
|
+
return { ok: false };
|
|
421
432
|
} finally {
|
|
422
|
-
runs.end(
|
|
433
|
+
runs.end(ticketId);
|
|
423
434
|
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
app.post("/api/tickets/:id/mode", (req, res) => {
|
|
427
|
-
const { mode } = req.body; // "agent" | "human" | null (inherit board default)
|
|
428
|
-
if (mode !== null && mode !== "agent" && mode !== "human") {
|
|
429
|
-
return res.status(400).json({ error: "mode must be 'agent', 'human', or null" });
|
|
430
|
-
}
|
|
431
|
-
const ticket = store.update(req.params.id, { mode });
|
|
432
|
-
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
433
|
-
res.json(ticket);
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
app.post("/api/tickets/:id/agent", (req, res) => {
|
|
437
|
-
const { agentId } = req.body;
|
|
438
|
-
if (agentId && !getAgent(agentId)) return res.status(400).json({ error: `no such agent: ${agentId}` });
|
|
439
|
-
const ticket = store.update(req.params.id, { agentId: agentId ?? null });
|
|
440
|
-
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
441
|
-
res.json(ticket);
|
|
442
|
-
});
|
|
435
|
+
}
|
|
443
436
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
437
|
+
/**
|
|
438
|
+
* The implementation run, factored out of its route for the same reason. Owns
|
|
439
|
+
* its own run registration, resolves mode/agent, resets the staging state to a
|
|
440
|
+
* clean QA budget, cuts the worktree, then either runs the agent straight into
|
|
441
|
+
* Staging (agent mode) or plans and waits for approval (human mode).
|
|
442
|
+
*/
|
|
443
|
+
async function runImplementation(ticketId) {
|
|
444
|
+
const ticket = store.get(ticketId);
|
|
445
|
+
if (!ticket || runs.isRunning(ticketId)) return;
|
|
448
446
|
|
|
449
447
|
const mode = resolveMode(ticket);
|
|
450
448
|
const agent = resolveAgent(ticket);
|
|
451
449
|
|
|
452
450
|
let signal;
|
|
453
451
|
try {
|
|
454
|
-
signal = runs.begin(
|
|
455
|
-
} catch
|
|
456
|
-
return
|
|
452
|
+
signal = runs.begin(ticketId, mode);
|
|
453
|
+
} catch {
|
|
454
|
+
return;
|
|
457
455
|
}
|
|
458
456
|
|
|
459
|
-
res.json({ started: true, mode, agentId: agent?.id ?? null });
|
|
460
457
|
// A fresh run resets the staging state so a re-run starts with a clean QA
|
|
461
458
|
// budget (the loop-stop guard counts from here).
|
|
462
|
-
store.update(
|
|
459
|
+
store.update(ticketId, {
|
|
463
460
|
status: "in_progress",
|
|
464
461
|
mode,
|
|
465
462
|
agentId: agent?.id ?? ticket.agentId,
|
|
@@ -471,11 +468,11 @@ export function createServer() {
|
|
|
471
468
|
escalated: false,
|
|
472
469
|
});
|
|
473
470
|
|
|
474
|
-
const onEvent = (event) => store.appendLog(
|
|
471
|
+
const onEvent = (event) => store.appendLog(ticketId, { type: "agent_event", event });
|
|
475
472
|
|
|
476
473
|
try {
|
|
477
|
-
const { dir: worktreeDir, branch, base } = await createTicketWorktree(
|
|
478
|
-
store.update(
|
|
474
|
+
const { dir: worktreeDir, branch, base } = await createTicketWorktree(ticketId);
|
|
475
|
+
store.update(ticketId, { worktree: worktreeDir, branch, baseBranch: base });
|
|
479
476
|
|
|
480
477
|
const prompt = buildImplementationPrompt(ticket);
|
|
481
478
|
|
|
@@ -483,17 +480,17 @@ export function createServer() {
|
|
|
483
480
|
// Full autonomy, single call.
|
|
484
481
|
const result = await runAgent({ prompt, cwd: worktreeDir, onEvent, signal, agent });
|
|
485
482
|
if (result.aborted) return; // cancel route already marked it
|
|
486
|
-
const diff = await diffWorktree(
|
|
487
|
-
store.update(
|
|
483
|
+
const diff = await diffWorktree(ticketId).catch(() => null);
|
|
484
|
+
store.update(ticketId, { status: "staging", diff, sessionId: result.sessionId, awaitingApproval: false });
|
|
488
485
|
// Validate in Staging before anything ships — the QA gate stands between
|
|
489
486
|
// a finished run and the PR.
|
|
490
|
-
await enterStaging(
|
|
487
|
+
await enterStaging(ticketId, { signal, onEvent, mode });
|
|
491
488
|
} else {
|
|
492
489
|
// Human-in-the-loop: plan only, no writes yet. The card shows the
|
|
493
490
|
// plan and waits for an explicit Approve before phase 2 runs.
|
|
494
491
|
const result = await planTicket({ prompt, cwd: worktreeDir, onEvent, signal, agent });
|
|
495
492
|
if (result.aborted) return;
|
|
496
|
-
store.update(
|
|
493
|
+
store.update(ticketId, {
|
|
497
494
|
status: "staging",
|
|
498
495
|
sessionId: result.sessionId,
|
|
499
496
|
plan: result.lastMessage,
|
|
@@ -501,12 +498,75 @@ export function createServer() {
|
|
|
501
498
|
});
|
|
502
499
|
}
|
|
503
500
|
} catch (err) {
|
|
504
|
-
store.appendLog(
|
|
505
|
-
store.cancel(
|
|
506
|
-
appendRunLog(store.get(
|
|
501
|
+
store.appendLog(ticketId, { type: "error", text: String(err.message || err) });
|
|
502
|
+
store.cancel(ticketId, `Run failed: ${err.message || err}`);
|
|
503
|
+
appendRunLog(store.get(ticketId), `failed: ${String(err.message || err).split("\n")[0].slice(0, 60)}`);
|
|
507
504
|
} finally {
|
|
508
|
-
runs.end(
|
|
505
|
+
runs.end(ticketId);
|
|
509
506
|
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Agent loop is hands-off from intake onward: a freshly created agent-mode
|
|
510
|
+
// ticket analyzes then runs on its own — the Telegram interview is the only
|
|
511
|
+
// human touch until the PR. A failed/cancelled analyze leaves it in Inbox
|
|
512
|
+
// rather than running blind. Human mode leaves it in Inbox for the buttons.
|
|
513
|
+
async function autoPipeline(ticketId) {
|
|
514
|
+
const analysis = await runAnalyze(ticketId);
|
|
515
|
+
if (!analysis?.ok) return;
|
|
516
|
+
if (store.get(ticketId)?.status !== "analyzed") return;
|
|
517
|
+
await runImplementation(ticketId);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// --- tickets ---
|
|
521
|
+
|
|
522
|
+
app.get("/api/tickets", (_req, res) => {
|
|
523
|
+
res.json(store.list());
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
app.post("/api/tickets", (req, res) => {
|
|
527
|
+
const { title, body, source, agentId, mode, priority, summary } = req.body;
|
|
528
|
+
if (!title?.trim()) return res.status(400).json({ error: "title is required" });
|
|
529
|
+
const ticket = store.create({ title: title.trim(), body: body ?? "", source, agentId, mode, priority, summary });
|
|
530
|
+
res.json(ticket);
|
|
531
|
+
if (resolveMode(ticket) === "agent") {
|
|
532
|
+
autoPipeline(ticket.id).catch((err) => store.appendLog(ticket.id, { type: "error", text: `auto-pipeline: ${String(err.message || err)}` }));
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
app.post("/api/tickets/:id/analyze", (req, res) => {
|
|
537
|
+
const ticket = store.get(req.params.id);
|
|
538
|
+
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
539
|
+
if (runs.isRunning(ticket.id)) return res.status(409).json({ error: "already running" });
|
|
540
|
+
res.json({ started: true });
|
|
541
|
+
runAnalyze(ticket.id).catch((err) => store.appendLog(ticket.id, { type: "error", text: String(err.message || err) }));
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
app.post("/api/tickets/:id/mode", (req, res) => {
|
|
545
|
+
const { mode } = req.body; // "agent" | "human" | null (inherit board default)
|
|
546
|
+
if (mode !== null && mode !== "agent" && mode !== "human") {
|
|
547
|
+
return res.status(400).json({ error: "mode must be 'agent', 'human', or null" });
|
|
548
|
+
}
|
|
549
|
+
const ticket = store.update(req.params.id, { mode });
|
|
550
|
+
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
551
|
+
res.json(ticket);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
app.post("/api/tickets/:id/agent", (req, res) => {
|
|
555
|
+
const { agentId } = req.body;
|
|
556
|
+
if (agentId && !getAgent(agentId)) return res.status(400).json({ error: `no such agent: ${agentId}` });
|
|
557
|
+
const ticket = store.update(req.params.id, { agentId: agentId ?? null });
|
|
558
|
+
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
559
|
+
res.json(ticket);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
app.post("/api/tickets/:id/run", (req, res) => {
|
|
563
|
+
const ticket = store.get(req.params.id);
|
|
564
|
+
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
565
|
+
if (runs.isRunning(ticket.id)) return res.status(409).json({ error: "already running" });
|
|
566
|
+
const mode = resolveMode(ticket);
|
|
567
|
+
const agent = resolveAgent(ticket);
|
|
568
|
+
res.json({ started: true, mode, agentId: agent?.id ?? null });
|
|
569
|
+
runImplementation(ticket.id).catch((err) => store.appendLog(ticket.id, { type: "error", text: String(err.message || err) }));
|
|
510
570
|
});
|
|
511
571
|
|
|
512
572
|
app.post("/api/tickets/:id/approve", async (req, res) => {
|
|
@@ -693,7 +753,7 @@ export function createServer() {
|
|
|
693
753
|
// ouro already holding the port. Without it, a probe that gets a 200 only
|
|
694
754
|
// proves someone is listening — not that the process we just spawned is
|
|
695
755
|
// the one answering.
|
|
696
|
-
res.json({ backend: getBackendName(), defaultMode: getDefaultMode(), autoShip: getAutoShip(), pid: process.pid, config });
|
|
756
|
+
res.json({ backend: getBackendName(), defaultMode: getDefaultMode(), autoShip: getAutoShip(), maxQaAttempts: getMaxQaAttempts(), pid: process.pid, config });
|
|
697
757
|
});
|
|
698
758
|
|
|
699
759
|
app.post("/api/config/backend", (req, res) => {
|