runcastle 1.0.24 → 1.0.26
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/bin/runcastle.js +2413 -895
- package/drizzle/0010_misty_ken_ellis.sql +24 -0
- package/drizzle/0011_steep_ted_forrester.sql +2 -0
- package/drizzle/0012_greedy_lionheart.sql +1 -0
- package/drizzle/0013_sudden_naoko.sql +21 -0
- package/drizzle/meta/0010_snapshot.json +757 -0
- package/drizzle/meta/0011_snapshot.json +771 -0
- package/drizzle/meta/0012_snapshot.json +778 -0
- package/drizzle/meta/0013_snapshot.json +785 -0
- package/drizzle/meta/_journal.json +28 -0
- package/index.js +2407 -889
- package/package.json +1 -1
- package/skills/burner/prepare-project.md +103 -0
- package/skills/packs/README.md +1 -1
- package/skills/packs/runcastle/skills/tickets/SKILL.md +22 -6
- package/web/assets/index-BLrA7l4N.css +1 -0
- package/web/assets/index-BcwSVMee.js +138 -0
- package/web/index.html +2 -2
- package/web/assets/index-Cpwb7Nqn.js +0 -110
- package/web/assets/index-DBVAMsFt.css +0 -1
package/index.js
CHANGED
|
@@ -21,19 +21,38 @@ __export(exports_paths, {
|
|
|
21
21
|
worktreeDir: () => worktreeDir,
|
|
22
22
|
sessionDir: () => sessionDir,
|
|
23
23
|
sandboxBuildDir: () => sandboxBuildDir,
|
|
24
|
+
sameDataDir: () => sameDataDir,
|
|
25
|
+
projectWorktreesDir: () => projectWorktreesDir,
|
|
26
|
+
prodDataDir: () => prodDataDir,
|
|
24
27
|
logsDir: () => logsDir,
|
|
25
28
|
featureDocsRel: () => featureDocsRel,
|
|
26
29
|
envPath: () => envPath,
|
|
30
|
+
devDataDir: () => devDataDir,
|
|
27
31
|
dbPath: () => dbPath,
|
|
28
32
|
dataDir: () => dataDir,
|
|
29
33
|
configPath: () => configPath,
|
|
30
34
|
burnCacheDir: () => burnCacheDir
|
|
31
35
|
});
|
|
32
36
|
import { homedir } from "os";
|
|
33
|
-
import { join } from "path";
|
|
34
|
-
function
|
|
37
|
+
import { join, resolve } from "path";
|
|
38
|
+
function prodDataDir() {
|
|
35
39
|
return join(homedir(), ".runcastle");
|
|
36
40
|
}
|
|
41
|
+
function devDataDir() {
|
|
42
|
+
const override = process.env.RUNCASTLE_DEV_DATA_DIR;
|
|
43
|
+
return override ? resolve(override) : join(homedir(), ".runcastle-dev");
|
|
44
|
+
}
|
|
45
|
+
function dataDir() {
|
|
46
|
+
const override = process.env.RUNCASTLE_DATA_DIR;
|
|
47
|
+
return override ? resolve(override) : prodDataDir();
|
|
48
|
+
}
|
|
49
|
+
function sameDataDir(a, b) {
|
|
50
|
+
const canon = (p) => {
|
|
51
|
+
const abs = resolve(p).replace(/[\\/]+$/, "");
|
|
52
|
+
return process.platform === "win32" ? abs.toLowerCase().replace(/\//g, "\\") : abs;
|
|
53
|
+
};
|
|
54
|
+
return canon(a) === canon(b);
|
|
55
|
+
}
|
|
37
56
|
function dbPath() {
|
|
38
57
|
return join(dataDir(), "runcastle.db");
|
|
39
58
|
}
|
|
@@ -49,8 +68,11 @@ function logsDir() {
|
|
|
49
68
|
function sessionDir(id) {
|
|
50
69
|
return join(dataDir(), "sessions", id);
|
|
51
70
|
}
|
|
71
|
+
function projectWorktreesDir(projectId) {
|
|
72
|
+
return join(dataDir(), "worktrees", projectId);
|
|
73
|
+
}
|
|
52
74
|
function worktreeDir(projectId, slug) {
|
|
53
|
-
return join(
|
|
75
|
+
return join(projectWorktreesDir(projectId), slug);
|
|
54
76
|
}
|
|
55
77
|
function burnCacheDir(pm) {
|
|
56
78
|
return join(dataDir(), "cache", pm);
|
|
@@ -68,6 +90,20 @@ import * as z from "zod";
|
|
|
68
90
|
function resolveSandboxImage(config) {
|
|
69
91
|
return config.sandboxImage ?? DEFAULT_SANDBOX_IMAGE;
|
|
70
92
|
}
|
|
93
|
+
function resolvePreparedSettings(config, project) {
|
|
94
|
+
const pick = (a, b) => {
|
|
95
|
+
const own = a?.trim();
|
|
96
|
+
if (own)
|
|
97
|
+
return own;
|
|
98
|
+
const inherited = b?.trim();
|
|
99
|
+
return inherited || undefined;
|
|
100
|
+
};
|
|
101
|
+
return {
|
|
102
|
+
setupCommand: pick(project?.setupCommand, config.setupCommand),
|
|
103
|
+
verifyCommands: pick(project?.verifyCommands, config.verifyCommands),
|
|
104
|
+
knownFailures: pick(project?.knownFailures, config.knownFailures)
|
|
105
|
+
};
|
|
106
|
+
}
|
|
71
107
|
function resolveModel(step, config, project, runOverride) {
|
|
72
108
|
return runOverride ?? config.stepModels[step] ?? project?.model ?? config.model;
|
|
73
109
|
}
|
|
@@ -93,6 +129,7 @@ var init_config = __esm(() => {
|
|
|
93
129
|
"revisit",
|
|
94
130
|
"research",
|
|
95
131
|
"implement",
|
|
132
|
+
"prepare",
|
|
96
133
|
"smoke"
|
|
97
134
|
];
|
|
98
135
|
ModelStep = z.enum(MODEL_STEPS);
|
|
@@ -101,6 +138,7 @@ var init_config = __esm(() => {
|
|
|
101
138
|
model: z.string().default("claude-opus-5"),
|
|
102
139
|
stepModels: z.partialRecord(ModelStep, z.string()).default({ smoke: DEFAULT_SMOKE_MODEL }),
|
|
103
140
|
sandbox: z.enum(["docker", "podman", "noSandbox"]).default("docker"),
|
|
141
|
+
sessionMcp: z.enum(["inherit", "runcastleOnly"]).default("inherit"),
|
|
104
142
|
mainBranch: z.string().default("main"),
|
|
105
143
|
sandboxImage: z.string().optional(),
|
|
106
144
|
burnConcurrency: z.number().int().min(1).max(8).default(3),
|
|
@@ -109,6 +147,7 @@ var init_config = __esm(() => {
|
|
|
109
147
|
burnMaxIterations: z.number().int().min(1).max(10).default(3),
|
|
110
148
|
burnAttempts: z.number().int().min(1).max(5).default(3),
|
|
111
149
|
burnConflictAttempts: z.number().int().min(0).max(3).default(2),
|
|
150
|
+
autoPrepare: z.boolean().default(true),
|
|
112
151
|
setupCommand: z.string().optional(),
|
|
113
152
|
verifyCommands: z.string().optional(),
|
|
114
153
|
knownFailures: z.string().optional(),
|
|
@@ -141,6 +180,8 @@ function loadConfig(env = process.env) {
|
|
|
141
180
|
overrides.smokeModel = env.RUNCASTLE_SMOKE_MODEL;
|
|
142
181
|
if (env.RUNCASTLE_SANDBOX)
|
|
143
182
|
overrides.sandbox = env.RUNCASTLE_SANDBOX;
|
|
183
|
+
if (env.RUNCASTLE_SESSION_MCP)
|
|
184
|
+
overrides.sessionMcp = env.RUNCASTLE_SESSION_MCP;
|
|
144
185
|
if (env.RUNCASTLE_MAIN_BRANCH)
|
|
145
186
|
overrides.mainBranch = env.RUNCASTLE_MAIN_BRANCH;
|
|
146
187
|
if (env.RUNCASTLE_SANDBOX_IMAGE)
|
|
@@ -202,7 +243,7 @@ var init_ids = () => {};
|
|
|
202
243
|
|
|
203
244
|
// ../core/src/schemas.ts
|
|
204
245
|
import * as z2 from "zod";
|
|
205
|
-
var Phase, TicketStatus, SessionKind, RunStatus, FeatureStatus, SessionStatus, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, Project, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
|
|
246
|
+
var Phase, TicketStatus, SessionKind, RunStatus, FeatureStatus, SessionStatus, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, PREPARED_KEYS, PreparedKey, FindingSource, Project, ProjectFinding, PrepStatus, PrepRun, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
|
|
206
247
|
var init_schemas = __esm(() => {
|
|
207
248
|
Phase = z2.enum([
|
|
208
249
|
"ideation",
|
|
@@ -213,7 +254,7 @@ var init_schemas = __esm(() => {
|
|
|
213
254
|
"shipped"
|
|
214
255
|
]);
|
|
215
256
|
TicketStatus = z2.enum(["pending", "burning", "done", "failed", "cancelled"]);
|
|
216
|
-
SessionKind = z2.enum(["ideation", "qa", "waypoint", "converge", "revisit"]);
|
|
257
|
+
SessionKind = z2.enum(["ideation", "qa", "waypoint", "converge", "revisit", "prepare"]);
|
|
217
258
|
RunStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
|
|
218
259
|
FeatureStatus = z2.enum(["active", "shipped", "archived"]);
|
|
219
260
|
SessionStatus = z2.enum(["launching", "live", "ended"]);
|
|
@@ -255,13 +296,50 @@ var init_schemas = __esm(() => {
|
|
|
255
296
|
lastSessionId: z2.string().optional(),
|
|
256
297
|
summary: z2.string().optional()
|
|
257
298
|
});
|
|
299
|
+
PREPARED_KEYS = [
|
|
300
|
+
"setupCommand",
|
|
301
|
+
"verifyCommands",
|
|
302
|
+
"knownFailures",
|
|
303
|
+
"devCommand",
|
|
304
|
+
"driveSetupCommand",
|
|
305
|
+
"driveStopCommand",
|
|
306
|
+
"driveEnv",
|
|
307
|
+
"dbResetCommand"
|
|
308
|
+
];
|
|
309
|
+
PreparedKey = z2.enum(PREPARED_KEYS);
|
|
310
|
+
FindingSource = z2.enum(["prep", "human", "session"]);
|
|
258
311
|
Project = z2.object({
|
|
259
312
|
id: z2.string(),
|
|
260
313
|
name: z2.string(),
|
|
261
314
|
repoPath: z2.string(),
|
|
262
315
|
mainBranch: z2.string(),
|
|
263
316
|
devCommand: z2.string().optional(),
|
|
264
|
-
model: z2.string().optional()
|
|
317
|
+
model: z2.string().optional(),
|
|
318
|
+
setupCommand: z2.string().optional(),
|
|
319
|
+
verifyCommands: z2.string().optional(),
|
|
320
|
+
knownFailures: z2.string().optional(),
|
|
321
|
+
dbResetCommand: z2.string().optional(),
|
|
322
|
+
driveSetupCommand: z2.string().optional(),
|
|
323
|
+
driveStopCommand: z2.string().optional(),
|
|
324
|
+
driveEnv: z2.string().optional()
|
|
325
|
+
});
|
|
326
|
+
ProjectFinding = z2.object({
|
|
327
|
+
key: PreparedKey,
|
|
328
|
+
source: FindingSource,
|
|
329
|
+
evidence: z2.string().optional(),
|
|
330
|
+
establishedAt: z2.number(),
|
|
331
|
+
establishedSha: z2.string().optional(),
|
|
332
|
+
staleCommits: z2.number().optional()
|
|
333
|
+
});
|
|
334
|
+
PrepStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
|
|
335
|
+
PrepRun = z2.object({
|
|
336
|
+
id: z2.string(),
|
|
337
|
+
projectId: z2.string(),
|
|
338
|
+
status: PrepStatus,
|
|
339
|
+
startedAt: z2.number(),
|
|
340
|
+
endedAt: z2.number().optional(),
|
|
341
|
+
summary: z2.string().optional(),
|
|
342
|
+
headSha: z2.string().optional()
|
|
265
343
|
});
|
|
266
344
|
Feature = z2.object({
|
|
267
345
|
id: z2.string(),
|
|
@@ -278,7 +356,8 @@ var init_schemas = __esm(() => {
|
|
|
278
356
|
});
|
|
279
357
|
SessionRow = z2.object({
|
|
280
358
|
id: z2.string(),
|
|
281
|
-
featureId: z2.string(),
|
|
359
|
+
featureId: z2.string().optional(),
|
|
360
|
+
projectId: z2.string().optional(),
|
|
282
361
|
kind: SessionKind,
|
|
283
362
|
ccSessionId: z2.string().optional(),
|
|
284
363
|
transcriptPath: z2.string().optional(),
|
|
@@ -460,8 +539,8 @@ var init_pipeline = __esm(() => {
|
|
|
460
539
|
});
|
|
461
540
|
|
|
462
541
|
// ../core/src/db-schema.ts
|
|
463
|
-
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
464
|
-
var projects, features, sessions, tickets, waypoints, runs, events, gateOverrides;
|
|
542
|
+
import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
543
|
+
var projects, projectFindings, projectPreps, features, sessions, tickets, waypoints, runs, events, gateOverrides;
|
|
465
544
|
var init_db_schema = __esm(() => {
|
|
466
545
|
projects = sqliteTable("projects", {
|
|
467
546
|
id: text("id").primaryKey(),
|
|
@@ -471,7 +550,31 @@ var init_db_schema = __esm(() => {
|
|
|
471
550
|
devCommand: text("dev_command"),
|
|
472
551
|
model: text("model"),
|
|
473
552
|
sandbox: text("sandbox"),
|
|
474
|
-
closedAt: integer("closed_at")
|
|
553
|
+
closedAt: integer("closed_at"),
|
|
554
|
+
setupCommand: text("setup_command"),
|
|
555
|
+
verifyCommands: text("verify_commands"),
|
|
556
|
+
knownFailures: text("known_failures"),
|
|
557
|
+
dbResetCommand: text("db_reset_command"),
|
|
558
|
+
driveSetupCommand: text("drive_setup_command"),
|
|
559
|
+
driveStopCommand: text("drive_stop_command"),
|
|
560
|
+
driveEnv: text("drive_env")
|
|
561
|
+
});
|
|
562
|
+
projectFindings = sqliteTable("project_findings", {
|
|
563
|
+
projectId: text("project_id").notNull(),
|
|
564
|
+
key: text("key").notNull(),
|
|
565
|
+
source: text("source").notNull().$type(),
|
|
566
|
+
evidence: text("evidence"),
|
|
567
|
+
establishedAt: integer("established_at").notNull(),
|
|
568
|
+
establishedSha: text("established_sha")
|
|
569
|
+
}, (t) => [primaryKey({ columns: [t.projectId, t.key] })]);
|
|
570
|
+
projectPreps = sqliteTable("project_preps", {
|
|
571
|
+
id: text("id").primaryKey(),
|
|
572
|
+
projectId: text("project_id").notNull(),
|
|
573
|
+
status: text("status").notNull().$type(),
|
|
574
|
+
startedAt: integer("started_at").notNull(),
|
|
575
|
+
endedAt: integer("ended_at"),
|
|
576
|
+
summary: text("summary"),
|
|
577
|
+
headSha: text("head_sha")
|
|
475
578
|
});
|
|
476
579
|
features = sqliteTable("features", {
|
|
477
580
|
id: text("id").primaryKey(),
|
|
@@ -488,7 +591,8 @@ var init_db_schema = __esm(() => {
|
|
|
488
591
|
});
|
|
489
592
|
sessions = sqliteTable("sessions", {
|
|
490
593
|
id: text("id").primaryKey(),
|
|
491
|
-
featureId: text("feature_id")
|
|
594
|
+
featureId: text("feature_id"),
|
|
595
|
+
projectId: text("project_id"),
|
|
492
596
|
kind: text("kind").notNull().$type(),
|
|
493
597
|
ccSessionId: text("cc_session_id"),
|
|
494
598
|
transcriptPath: text("transcript_path"),
|
|
@@ -575,7 +679,9 @@ var init_schema = __esm(() => {
|
|
|
575
679
|
waypoints,
|
|
576
680
|
runs,
|
|
577
681
|
events,
|
|
578
|
-
gateOverrides
|
|
682
|
+
gateOverrides,
|
|
683
|
+
projectFindings,
|
|
684
|
+
projectPreps
|
|
579
685
|
};
|
|
580
686
|
});
|
|
581
687
|
|
|
@@ -598,11 +704,11 @@ var init_client = __esm(() => {
|
|
|
598
704
|
|
|
599
705
|
// src/launcher/asset-paths.ts
|
|
600
706
|
import { existsSync as existsSync2 } from "fs";
|
|
601
|
-
import { join as join3, resolve } from "path";
|
|
707
|
+
import { join as join3, resolve as resolve2 } from "path";
|
|
602
708
|
function resolveAsset(envVar, fallback) {
|
|
603
709
|
const override = process.env[envVar];
|
|
604
710
|
if (override) {
|
|
605
|
-
const abs =
|
|
711
|
+
const abs = resolve2(override);
|
|
606
712
|
if (existsSync2(abs))
|
|
607
713
|
return abs;
|
|
608
714
|
throw new Error(`${envVar}=${override} does not exist (resolved ${abs})`);
|
|
@@ -1282,6 +1388,13 @@ function emit(ctx, featureId, e) {
|
|
|
1282
1388
|
function emitProject(ctx, projectId, e) {
|
|
1283
1389
|
return insertEvent(ctx, projectId, null, e);
|
|
1284
1390
|
}
|
|
1391
|
+
function emitForSession(ctx, session, e) {
|
|
1392
|
+
if (session.featureId)
|
|
1393
|
+
return emit(ctx, session.featureId, e);
|
|
1394
|
+
if (session.projectId)
|
|
1395
|
+
return emitProject(ctx, session.projectId, e);
|
|
1396
|
+
return null;
|
|
1397
|
+
}
|
|
1285
1398
|
function insertEvent(ctx, projectId, featureId, e) {
|
|
1286
1399
|
const row = ctx.db.insert(events).values({
|
|
1287
1400
|
projectId,
|
|
@@ -1340,7 +1453,14 @@ function rowToProject(row) {
|
|
|
1340
1453
|
repoPath: row.repoPath,
|
|
1341
1454
|
mainBranch: row.mainBranch,
|
|
1342
1455
|
devCommand: row.devCommand ?? undefined,
|
|
1343
|
-
model: row.model ?? undefined
|
|
1456
|
+
model: row.model ?? undefined,
|
|
1457
|
+
setupCommand: row.setupCommand ?? undefined,
|
|
1458
|
+
verifyCommands: row.verifyCommands ?? undefined,
|
|
1459
|
+
knownFailures: row.knownFailures ?? undefined,
|
|
1460
|
+
dbResetCommand: row.dbResetCommand ?? undefined,
|
|
1461
|
+
driveSetupCommand: row.driveSetupCommand ?? undefined,
|
|
1462
|
+
driveStopCommand: row.driveStopCommand ?? undefined,
|
|
1463
|
+
driveEnv: row.driveEnv ?? undefined
|
|
1344
1464
|
};
|
|
1345
1465
|
}
|
|
1346
1466
|
function rowToRun(row) {
|
|
@@ -1357,7 +1477,8 @@ function rowToRun(row) {
|
|
|
1357
1477
|
function rowToSession(row) {
|
|
1358
1478
|
return {
|
|
1359
1479
|
id: row.id,
|
|
1360
|
-
featureId: row.featureId,
|
|
1480
|
+
featureId: row.featureId ?? undefined,
|
|
1481
|
+
projectId: row.projectId ?? undefined,
|
|
1361
1482
|
kind: row.kind,
|
|
1362
1483
|
ccSessionId: row.ccSessionId ?? undefined,
|
|
1363
1484
|
transcriptPath: row.transcriptPath ?? undefined,
|
|
@@ -1574,7 +1695,7 @@ function releaseForSession(ctx, claimant) {
|
|
|
1574
1695
|
const held = ctx.db.select().from(waypoints).where(and3(eq3(waypoints.claimedBy, claimant), eq3(waypoints.status, "claimed"))).all().map(rowToWaypoint);
|
|
1575
1696
|
return held.map((w) => release(ctx, w.id));
|
|
1576
1697
|
}
|
|
1577
|
-
function
|
|
1698
|
+
function resolve3(ctx, id, disposition, summary) {
|
|
1578
1699
|
const wp = getWaypoint(ctx, id);
|
|
1579
1700
|
const before = listByFeature(ctx, wp.featureId);
|
|
1580
1701
|
const bySeqBefore = new Map(before.map((w) => [w.seq, w]));
|
|
@@ -1617,7 +1738,8 @@ import { and as and4, desc as desc2, eq as eq4, inArray, isNotNull, sql as sql2
|
|
|
1617
1738
|
function createSessionRow(ctx, input) {
|
|
1618
1739
|
const row = ctx.db.insert(sessions).values({
|
|
1619
1740
|
id: newId("sess"),
|
|
1620
|
-
featureId: input.featureId,
|
|
1741
|
+
featureId: input.featureId ?? null,
|
|
1742
|
+
projectId: input.projectId ?? null,
|
|
1621
1743
|
kind: input.kind,
|
|
1622
1744
|
ccSessionId: null,
|
|
1623
1745
|
transcriptPath: null,
|
|
@@ -1672,34 +1794,159 @@ function writeKickoffSequence(line, io, submitDelayMs = KICKOFF_SUBMIT_DELAY_MS)
|
|
|
1672
1794
|
}, submitDelayMs);
|
|
1673
1795
|
submit.unref?.();
|
|
1674
1796
|
}
|
|
1675
|
-
function
|
|
1676
|
-
const
|
|
1677
|
-
|
|
1678
|
-
|
|
1797
|
+
function stopTimers(d) {
|
|
1798
|
+
for (const t of d.timers)
|
|
1799
|
+
clearTimeout(t);
|
|
1800
|
+
d.timers.clear();
|
|
1801
|
+
}
|
|
1802
|
+
function forgetKickoff(sessionId) {
|
|
1803
|
+
const d = deliveries.get(sessionId);
|
|
1804
|
+
if (d)
|
|
1805
|
+
stopTimers(d);
|
|
1806
|
+
deliveries.delete(sessionId);
|
|
1807
|
+
pendingKickoffOverrides.delete(sessionId);
|
|
1808
|
+
}
|
|
1809
|
+
function ptyIo(sessionId) {
|
|
1810
|
+
return {
|
|
1811
|
+
write: (data) => ptyRegistry().get(sessionId)?.pty.write(data),
|
|
1812
|
+
alive: () => {
|
|
1813
|
+
const entry = ptyRegistry().get(sessionId);
|
|
1814
|
+
return !!entry && !entry.exited;
|
|
1815
|
+
}
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
function track(d, timer) {
|
|
1819
|
+
timer.unref?.();
|
|
1820
|
+
d.timers.add(timer);
|
|
1821
|
+
}
|
|
1822
|
+
function attemptKickoff(ctx, session, d, delayMs, clearFirst = false) {
|
|
1823
|
+
track(d, setTimeout(() => {
|
|
1824
|
+
if (d.settled)
|
|
1825
|
+
return;
|
|
1826
|
+
const io = ptyIo(session.id);
|
|
1827
|
+
if (!io.alive()) {
|
|
1828
|
+
d.settled = true;
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
d.attempts += 1;
|
|
1832
|
+
const attempt = d.attempts;
|
|
1679
1833
|
try {
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
data: { sessionId: session.id, kind: session.kind }
|
|
1691
|
-
});
|
|
1692
|
-
}
|
|
1834
|
+
if (clearFirst)
|
|
1835
|
+
io.write(CLEAR_INPUT);
|
|
1836
|
+
writeKickoffSequence(d.line, {
|
|
1837
|
+
write: io.write,
|
|
1838
|
+
alive: io.alive,
|
|
1839
|
+
onSubmitted: () => emitForSession(ctx, session, {
|
|
1840
|
+
type: "session.kickoff",
|
|
1841
|
+
message: attempt === 1 ? `${session.kind} session kicked off automatically` : `${session.kind} kickoff re-sent (attempt ${attempt}) \u2014 the first was never acknowledged`,
|
|
1842
|
+
data: { sessionId: session.id, kind: session.kind, attempt }
|
|
1843
|
+
})
|
|
1693
1844
|
});
|
|
1694
1845
|
} catch {}
|
|
1695
|
-
|
|
1846
|
+
armConfirmation(ctx, session, d);
|
|
1847
|
+
}, delayMs));
|
|
1848
|
+
}
|
|
1849
|
+
function armConfirmation(ctx, session, d) {
|
|
1850
|
+
track(d, setTimeout(() => {
|
|
1851
|
+
if (d.settled || d.confirmed)
|
|
1852
|
+
return;
|
|
1853
|
+
if (d.attempts < KICKOFF_MAX_ATTEMPTS && ptyIo(session.id).alive()) {
|
|
1854
|
+
attemptKickoff(ctx, session, d, 0, true);
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
settleUndelivered(ctx, session, d, "unacknowledged");
|
|
1858
|
+
}, KICKOFF_CONFIRM_MS));
|
|
1859
|
+
}
|
|
1860
|
+
function settleUndelivered(ctx, session, d, reason) {
|
|
1861
|
+
d.settled = true;
|
|
1862
|
+
stopTimers(d);
|
|
1863
|
+
emitForSession(ctx, session, {
|
|
1864
|
+
type: "session.kickoff_undelivered",
|
|
1865
|
+
message: reason === "superseded" ? `the ${session.kind} briefing was never delivered \u2014 you typed first, so runcastle stopped injecting it` : `the ${session.kind} briefing was typed ${d.attempts}\xD7 but Claude Code never acknowledged it \u2014 send it again from the session strip`,
|
|
1866
|
+
data: { sessionId: session.id, kind: session.kind, reason, attempts: d.attempts, line: d.line }
|
|
1867
|
+
});
|
|
1868
|
+
}
|
|
1869
|
+
function noteKickoffPrompt(ctx, sessionId, prompt) {
|
|
1870
|
+
const d = deliveries.get(sessionId);
|
|
1871
|
+
if (!d || d.confirmed)
|
|
1872
|
+
return;
|
|
1873
|
+
const session = getSessionRow(ctx, sessionId);
|
|
1874
|
+
if (!session)
|
|
1875
|
+
return;
|
|
1876
|
+
if (promptMatchesKickoff(d.line, prompt)) {
|
|
1877
|
+
d.confirmed = true;
|
|
1878
|
+
d.settled = true;
|
|
1879
|
+
stopTimers(d);
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
if (d.settled)
|
|
1883
|
+
return;
|
|
1884
|
+
settleUndelivered(ctx, session, d, "superseded");
|
|
1885
|
+
}
|
|
1886
|
+
function promptMatchesKickoff(line, prompt) {
|
|
1887
|
+
if (!prompt)
|
|
1888
|
+
return false;
|
|
1889
|
+
const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
|
|
1890
|
+
const want = norm(line);
|
|
1891
|
+
const got = norm(prompt);
|
|
1892
|
+
if (!want || !got)
|
|
1893
|
+
return false;
|
|
1894
|
+
return got.includes(want.slice(0, MATCH_PREFIX));
|
|
1895
|
+
}
|
|
1896
|
+
function resendKickoff(ctx, sessionId) {
|
|
1897
|
+
const session = getSessionRow(ctx, sessionId);
|
|
1898
|
+
if (!session)
|
|
1899
|
+
throw new Error(`unknown session ${sessionId}`);
|
|
1900
|
+
if (session.status === "ended")
|
|
1901
|
+
throw new Error("that session has ended \u2014 open a new terminal");
|
|
1902
|
+
if (!ptyIo(sessionId).alive())
|
|
1903
|
+
throw new Error("the terminal for that session is no longer running");
|
|
1904
|
+
const existing = deliveries.get(sessionId);
|
|
1905
|
+
const d = existing ?? {
|
|
1906
|
+
line: kickoffLineFor(session.kind, pendingKickoffOverrides.get(sessionId)),
|
|
1907
|
+
attempts: 0,
|
|
1908
|
+
confirmed: false,
|
|
1909
|
+
settled: false,
|
|
1910
|
+
timers: new Set
|
|
1911
|
+
};
|
|
1912
|
+
stopTimers(d);
|
|
1913
|
+
d.attempts = 0;
|
|
1914
|
+
d.confirmed = false;
|
|
1915
|
+
d.settled = false;
|
|
1916
|
+
deliveries.set(sessionId, d);
|
|
1917
|
+
attemptKickoff(ctx, session, d, 0, true);
|
|
1918
|
+
return { line: d.line };
|
|
1919
|
+
}
|
|
1920
|
+
function armSessionReadyWatchdog(ctx, session) {
|
|
1921
|
+
const timer = setTimeout(() => {
|
|
1922
|
+
const row = getSessionRow(ctx, session.id);
|
|
1923
|
+
if (!row || row.status !== "launching")
|
|
1924
|
+
return;
|
|
1925
|
+
if (!ptyIo(session.id).alive())
|
|
1926
|
+
return;
|
|
1927
|
+
emitForSession(ctx, session, {
|
|
1928
|
+
type: "session.not_ready",
|
|
1929
|
+
message: `the ${session.kind} terminal is open but Claude Code has not reported ready \u2014 check it for a prompt or dialog waiting on you, then send the briefing`,
|
|
1930
|
+
data: { sessionId: session.id, kind: session.kind }
|
|
1931
|
+
});
|
|
1932
|
+
}, SESSION_READY_TIMEOUT_MS);
|
|
1696
1933
|
timer.unref?.();
|
|
1697
1934
|
}
|
|
1935
|
+
function scheduleKickoff(ctx, session) {
|
|
1936
|
+
const line = kickoffLineFor(session.kind, pendingKickoffOverrides.get(session.id));
|
|
1937
|
+
pendingKickoffOverrides.delete(session.id);
|
|
1938
|
+
const existing = deliveries.get(session.id);
|
|
1939
|
+
if (existing)
|
|
1940
|
+
stopTimers(existing);
|
|
1941
|
+
const d = { line, attempts: 0, confirmed: false, settled: false, timers: new Set };
|
|
1942
|
+
deliveries.set(session.id, d);
|
|
1943
|
+
attemptKickoff(ctx, session, d, KICKOFF_DELAY_MS);
|
|
1944
|
+
}
|
|
1698
1945
|
function markSessionEnded(ctx, id) {
|
|
1699
1946
|
const existing = getSessionRow(ctx, id);
|
|
1700
1947
|
if (!existing)
|
|
1701
1948
|
return null;
|
|
1702
|
-
|
|
1949
|
+
forgetKickoff(id);
|
|
1703
1950
|
ctx.db.update(sessions).set({ status: "ended" }).where(eq4(sessions.id, id)).run();
|
|
1704
1951
|
return getSessionRow(ctx, id);
|
|
1705
1952
|
}
|
|
@@ -1707,11 +1954,19 @@ function mostRecentResumableSession(ctx, featureId, kind) {
|
|
|
1707
1954
|
const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.featureId, featureId), eq4(sessions.status, "ended"), isNotNull(sessions.ccSessionId), ...kind ? [eq4(sessions.kind, kind)] : [])).orderBy(desc2(sql2`rowid`)).limit(1).get();
|
|
1708
1955
|
return row ? rowToSession(row) : null;
|
|
1709
1956
|
}
|
|
1957
|
+
function activeProjectSession(ctx, projectId, kind) {
|
|
1958
|
+
const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.projectId, projectId), eq4(sessions.kind, kind), inArray(sessions.status, ["launching", "live"]))).orderBy(desc2(sql2`rowid`)).limit(1).get();
|
|
1959
|
+
return row ? rowToSession(row) : null;
|
|
1960
|
+
}
|
|
1961
|
+
function mostRecentResumableProjectSession(ctx, projectId, kind) {
|
|
1962
|
+
const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.projectId, projectId), eq4(sessions.kind, kind), eq4(sessions.status, "ended"), isNotNull(sessions.ccSessionId))).orderBy(desc2(sql2`rowid`)).limit(1).get();
|
|
1963
|
+
return row ? rowToSession(row) : null;
|
|
1964
|
+
}
|
|
1710
1965
|
function mostRecentLiveSession(ctx) {
|
|
1711
1966
|
const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc2(sql2`rowid`)).limit(1).get();
|
|
1712
1967
|
return row ? rowToSession(row) : null;
|
|
1713
1968
|
}
|
|
1714
|
-
var KICKOFF_DELAY_MS = 1500, KICKOFF_SUBMIT_DELAY_MS = 350, CONVERGE_KICKOFF_LINE, KICKOFF_LINES, RESUME_KICKOFF_PREFIX, pendingKickoffOverrides;
|
|
1969
|
+
var KICKOFF_DELAY_MS = 1500, KICKOFF_SUBMIT_DELAY_MS = 350, KICKOFF_CONFIRM_MS = 12000, KICKOFF_MAX_ATTEMPTS = 3, CLEAR_INPUT = "\x15", SESSION_READY_TIMEOUT_MS = 25000, CONVERGE_KICKOFF_LINE, KICKOFF_LINES, RESUME_KICKOFF_PREFIX, pendingKickoffOverrides, deliveries, MATCH_PREFIX = 40;
|
|
1715
1970
|
var init_sessions = __esm(() => {
|
|
1716
1971
|
init_src();
|
|
1717
1972
|
init_schema();
|
|
@@ -1725,10 +1980,12 @@ var init_sessions = __esm(() => {
|
|
|
1725
1980
|
qa: "Proceed with your task: invoke the /runcastle:qa skill and answer questions from the " + "docs and code \u2014 do not advance phases or emit tickets.",
|
|
1726
1981
|
waypoint: "Proceed with your task: invoke the /runcastle:waypoint skill and work your assigned " + "waypoint to a resolution.",
|
|
1727
1982
|
converge: CONVERGE_KICKOFF_LINE,
|
|
1728
|
-
revisit: "Proceed with your task: invoke the /runcastle:revisit skill and work through what the " + "human brings up."
|
|
1983
|
+
revisit: "Proceed with your task: invoke the /runcastle:revisit skill and work through what the " + "human brings up.",
|
|
1984
|
+
prepare: "Proceed with your task: work through the unestablished preparation fields with the human. " + "Start by telling them which fields are still open and what you need from them for each; " + "ask before running anything that touches their database or services."
|
|
1729
1985
|
};
|
|
1730
1986
|
RESUME_KICKOFF_PREFIX = "We are picking this conversation back up \u2014 runcastle restarted and closed the terminal, " + "but this conversation is intact. Do NOT start over: tell me in one or two lines where we " + "left off and what is next, then carry on. Your original instruction was: ";
|
|
1731
1987
|
pendingKickoffOverrides = new Map;
|
|
1988
|
+
deliveries = new Map;
|
|
1732
1989
|
});
|
|
1733
1990
|
|
|
1734
1991
|
// src/launcher/reconcile.ts
|
|
@@ -1742,7 +1999,7 @@ function reconcileStaleSessions(ctx) {
|
|
|
1742
1999
|
continue;
|
|
1743
2000
|
markSessionEnded(ctx, session.id);
|
|
1744
2001
|
const released = releaseForSession(ctx, session.id);
|
|
1745
|
-
|
|
2002
|
+
emitForSession(ctx, session, {
|
|
1746
2003
|
type: "session.reconciled",
|
|
1747
2004
|
message: `session marked ended at boot \u2014 the server restarted while it was ${session.status}`,
|
|
1748
2005
|
data: {
|
|
@@ -1764,351 +2021,288 @@ var init_reconcile = __esm(() => {
|
|
|
1764
2021
|
init_sessions();
|
|
1765
2022
|
});
|
|
1766
2023
|
|
|
1767
|
-
// src/launcher/
|
|
1768
|
-
function setRuntimeCtx(ctx) {
|
|
1769
|
-
current = ctx;
|
|
1770
|
-
}
|
|
1771
|
-
async function getRuntimeCtx() {
|
|
1772
|
-
if (current)
|
|
1773
|
-
return current;
|
|
1774
|
-
const [{ createDb: createDb2 }, { dbPath: dbPath2 }, { loadConfig: loadConfig2 }] = await Promise.all([
|
|
1775
|
-
Promise.resolve().then(() => (init_client(), exports_client)),
|
|
1776
|
-
Promise.resolve().then(() => (init_paths(), exports_paths)),
|
|
1777
|
-
Promise.resolve().then(() => (init_config_load(), exports_config_load))
|
|
1778
|
-
]);
|
|
1779
|
-
const db = createDb2(dbPath2());
|
|
1780
|
-
current = { db, config: loadConfig2() };
|
|
1781
|
-
return current;
|
|
1782
|
-
}
|
|
1783
|
-
var current = null;
|
|
1784
|
-
|
|
1785
|
-
// src/services/feature-docs.ts
|
|
2024
|
+
// src/launcher/skills-root.ts
|
|
1786
2025
|
import { existsSync as existsSync5 } from "fs";
|
|
1787
|
-
import { join as join5 } from "path";
|
|
1788
|
-
function
|
|
1789
|
-
const
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
2026
|
+
import { dirname, join as join5, resolve as resolve4 } from "path";
|
|
2027
|
+
function resolveSkillsRoot(fromDir) {
|
|
2028
|
+
const override = process.env[SKILLS_DIR_ENV];
|
|
2029
|
+
if (override) {
|
|
2030
|
+
const root = resolve4(override);
|
|
2031
|
+
if (existsSync5(join5(root, SKILLS_MARKER)))
|
|
2032
|
+
return root;
|
|
2033
|
+
throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
|
|
2034
|
+
}
|
|
2035
|
+
const searched = [];
|
|
2036
|
+
let dir = fromDir;
|
|
2037
|
+
for (let i = 0;i < 8; i += 1) {
|
|
2038
|
+
const candidate = join5(dir, "packages", "skills");
|
|
2039
|
+
searched.push(candidate);
|
|
2040
|
+
if (existsSync5(join5(candidate, SKILLS_MARKER)))
|
|
2041
|
+
return candidate;
|
|
2042
|
+
const parent = dirname(dir);
|
|
2043
|
+
if (parent === dir)
|
|
2044
|
+
break;
|
|
2045
|
+
dir = parent;
|
|
2046
|
+
}
|
|
2047
|
+
throw new Error(`runcastle skills root not found; searched:
|
|
2048
|
+
${searched.join(`
|
|
2049
|
+
`)}`);
|
|
1796
2050
|
}
|
|
1797
|
-
var
|
|
1798
|
-
|
|
2051
|
+
var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
|
|
2052
|
+
var init_skills_root = __esm(() => {
|
|
2053
|
+
SKILLS_MARKER = join5("packs", "runcastle");
|
|
1799
2054
|
});
|
|
1800
2055
|
|
|
1801
|
-
// src/
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
return {
|
|
1805
|
-
id: row.id,
|
|
1806
|
-
featureId: row.featureId,
|
|
1807
|
-
seq: row.seq,
|
|
1808
|
-
title: row.title,
|
|
1809
|
-
goal: row.goal,
|
|
1810
|
-
context: row.context,
|
|
1811
|
-
acceptanceCriteria: row.acceptanceCriteria,
|
|
1812
|
-
seams: row.seams,
|
|
1813
|
-
blockedBy: row.blockedBy,
|
|
1814
|
-
status: row.status,
|
|
1815
|
-
commits: row.commits,
|
|
1816
|
-
error: row.error ?? undefined,
|
|
1817
|
-
attemptBranch: row.attemptBranch ?? undefined,
|
|
1818
|
-
conflictFiles: row.conflictFiles ?? undefined
|
|
1819
|
-
};
|
|
2056
|
+
// src/pty/dev-pane.ts
|
|
2057
|
+
function drivePaneId(featureId) {
|
|
2058
|
+
return `${DRIVE_PREFIX}${featureId}`;
|
|
1820
2059
|
}
|
|
1821
|
-
function
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
} catch (e) {
|
|
1825
|
-
if (e instanceof BlockingEdgeError)
|
|
1826
|
-
throw new InvalidInputError(e.message);
|
|
1827
|
-
throw e;
|
|
1828
|
-
}
|
|
2060
|
+
function sniffDevUrl(text2) {
|
|
2061
|
+
const m = text2.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s"'<>`\x1b]*)?/i);
|
|
2062
|
+
return m ? m[0] : undefined;
|
|
1829
2063
|
}
|
|
1830
|
-
function
|
|
1831
|
-
|
|
2064
|
+
function devSpawnTarget(devCommand) {
|
|
2065
|
+
if (process.platform === "win32") {
|
|
2066
|
+
return { file: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", devCommand] };
|
|
2067
|
+
}
|
|
2068
|
+
return { file: "/bin/sh", args: ["-c", devCommand] };
|
|
1832
2069
|
}
|
|
1833
|
-
function
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
const
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
2070
|
+
function startDevPane(input) {
|
|
2071
|
+
const { ctx, featureId, repoPath, devCommand, env, onUrl } = input;
|
|
2072
|
+
const paneId = drivePaneId(featureId);
|
|
2073
|
+
const { file, args } = devSpawnTarget(devCommand);
|
|
2074
|
+
try {
|
|
2075
|
+
ptyRegistry().create({
|
|
2076
|
+
sessionId: paneId,
|
|
2077
|
+
cmd: file,
|
|
2078
|
+
args,
|
|
2079
|
+
opts: { cwd: repoPath, env: env ?? process.env, cols: 80, rows: 24, useConpty: true }
|
|
2080
|
+
});
|
|
2081
|
+
} catch (err) {
|
|
2082
|
+
emit(ctx, featureId, {
|
|
2083
|
+
type: "testdrive.dev_failed",
|
|
2084
|
+
message: `dev server failed to start: ${err instanceof Error ? err.message : String(err)}`,
|
|
2085
|
+
data: { paneId }
|
|
2086
|
+
});
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
let scanned = "";
|
|
2090
|
+
let found = false;
|
|
2091
|
+
const sniffer = {
|
|
2092
|
+
sendData(chunk) {
|
|
2093
|
+
if (found)
|
|
2094
|
+
return;
|
|
2095
|
+
scanned = (scanned + chunk.toString("utf8")).slice(-SNIFF_WINDOW);
|
|
2096
|
+
const url = sniffDevUrl(scanned);
|
|
2097
|
+
if (url) {
|
|
2098
|
+
found = true;
|
|
2099
|
+
onUrl(url);
|
|
2100
|
+
}
|
|
2101
|
+
},
|
|
2102
|
+
sendControl() {}
|
|
2103
|
+
};
|
|
2104
|
+
ptyRegistry().attach(paneId, sniffer);
|
|
1856
2105
|
emit(ctx, featureId, {
|
|
1857
|
-
type: "
|
|
1858
|
-
message:
|
|
1859
|
-
data: {
|
|
2106
|
+
type: "testdrive.dev_started",
|
|
2107
|
+
message: `dev server spawned: ${devCommand}`,
|
|
2108
|
+
data: { paneId }
|
|
1860
2109
|
});
|
|
1861
|
-
return
|
|
2110
|
+
return paneId;
|
|
1862
2111
|
}
|
|
1863
|
-
function
|
|
1864
|
-
const
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
2112
|
+
function stopDevPane(paneId) {
|
|
2113
|
+
const reg = ptyRegistry();
|
|
2114
|
+
const entry = reg.get(paneId);
|
|
2115
|
+
if (entry && !entry.exited && process.platform !== "win32") {
|
|
2116
|
+
try {
|
|
2117
|
+
process.kill(-entry.pty.pid, "SIGTERM");
|
|
2118
|
+
} catch {}
|
|
2119
|
+
}
|
|
2120
|
+
reg.kill(paneId);
|
|
2121
|
+
reg.remove(paneId);
|
|
1868
2122
|
}
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
2123
|
+
var DRIVE_PREFIX = "drive:", SNIFF_WINDOW = 8192;
|
|
2124
|
+
var init_dev_pane = __esm(() => {
|
|
2125
|
+
init_events();
|
|
2126
|
+
init_registry();
|
|
2127
|
+
});
|
|
2128
|
+
|
|
2129
|
+
// src/services/drive-hooks.ts
|
|
2130
|
+
import { spawn as spawn2 } from "child_process";
|
|
2131
|
+
function tailLines(text2, max = OUTPUT_TAIL_LINES) {
|
|
2132
|
+
const lines = text2.replace(/\r\n/g, `
|
|
2133
|
+
`).split(`
|
|
2134
|
+
`);
|
|
2135
|
+
while (lines.length > 0 && lines[lines.length - 1]?.trim() === "")
|
|
2136
|
+
lines.pop();
|
|
2137
|
+
return lines.slice(-max).join(`
|
|
2138
|
+
`).trim();
|
|
2139
|
+
}
|
|
2140
|
+
function hookSpawnTarget(command) {
|
|
2141
|
+
if (process.platform === "win32") {
|
|
2142
|
+
return {
|
|
2143
|
+
file: process.env.ComSpec ?? "cmd.exe",
|
|
2144
|
+
args: ["/d", "/s", "/c", `"${command}"`],
|
|
2145
|
+
verbatim: true
|
|
2146
|
+
};
|
|
1872
2147
|
}
|
|
2148
|
+
return { file: "/bin/sh", args: ["-c", command], verbatim: false };
|
|
1873
2149
|
}
|
|
1874
|
-
function
|
|
1875
|
-
const
|
|
1876
|
-
|
|
1877
|
-
const
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
}
|
|
1912
|
-
function sweepOrphanedBurning(ctx, featureId, reason) {
|
|
1913
|
-
const orphaned = ctx.db.select().from(tickets).where(and5(eq5(tickets.featureId, featureId), eq5(tickets.status, "burning"))).all().map(rowToTicket);
|
|
1914
|
-
for (const t of orphaned) {
|
|
1915
|
-
updateTicket(ctx, t.id, { status: "failed", error: reason });
|
|
1916
|
-
emit(ctx, featureId, {
|
|
1917
|
-
type: "ticket.failed",
|
|
1918
|
-
message: `ticket ${t.seq} failed: ${reason}`,
|
|
1919
|
-
ticketId: t.id,
|
|
1920
|
-
data: { error: reason, orphaned: true }
|
|
1921
|
-
});
|
|
1922
|
-
}
|
|
1923
|
-
return orphaned.map((t) => ({ ...t, status: "failed", error: reason }));
|
|
1924
|
-
}
|
|
1925
|
-
function updateTicket(ctx, id, patch) {
|
|
1926
|
-
const current2 = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
|
|
1927
|
-
if (!current2)
|
|
1928
|
-
throw new NotFoundError(`ticket ${id} not found`);
|
|
1929
|
-
const set = {};
|
|
1930
|
-
if (patch.status !== undefined)
|
|
1931
|
-
set.status = patch.status;
|
|
1932
|
-
if (patch.commits !== undefined)
|
|
1933
|
-
set.commits = patch.commits;
|
|
1934
|
-
if (patch.error !== undefined)
|
|
1935
|
-
set.error = patch.error;
|
|
1936
|
-
if (patch.attemptBranch !== undefined)
|
|
1937
|
-
set.attemptBranch = patch.attemptBranch;
|
|
1938
|
-
if (patch.conflictFiles !== undefined)
|
|
1939
|
-
set.conflictFiles = patch.conflictFiles;
|
|
1940
|
-
ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
|
|
1941
|
-
emit(ctx, current2.featureId, {
|
|
1942
|
-
type: "ticket.updated",
|
|
1943
|
-
message: `ticket ${current2.seq} \u2192 ${patch.status ?? current2.status}`,
|
|
1944
|
-
ticketId: id,
|
|
1945
|
-
data: patch
|
|
1946
|
-
});
|
|
1947
|
-
const updated = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
|
|
1948
|
-
return rowToTicket(updated);
|
|
1949
|
-
}
|
|
1950
|
-
var MUTABLE_STATUSES;
|
|
1951
|
-
var init_tickets = __esm(() => {
|
|
1952
|
-
init_src();
|
|
1953
|
-
init_schema();
|
|
1954
|
-
init_errors();
|
|
1955
|
-
init_events();
|
|
1956
|
-
MUTABLE_STATUSES = new Set(["pending", "failed"]);
|
|
1957
|
-
});
|
|
1958
|
-
|
|
1959
|
-
// src/services/gates.ts
|
|
1960
|
-
import { existsSync as existsSync6 } from "fs";
|
|
1961
|
-
function checkGate(ctx, check, feature) {
|
|
1962
|
-
switch (check) {
|
|
1963
|
-
case "decisions-file-exists":
|
|
1964
|
-
return fileGate(ctx, feature, "decisions.md", "run the ideation session to capture decisions first");
|
|
1965
|
-
case "all-waypoints-terminal": {
|
|
1966
|
-
const wps = listByFeature(ctx, feature.id);
|
|
1967
|
-
if (wps.length === 0) {
|
|
1968
|
-
return { satisfied: false, reason: "no waypoints charted yet \u2014 chart the map before converging" };
|
|
1969
|
-
}
|
|
1970
|
-
const open = wps.filter((w) => w.status !== "resolved" && w.status !== "dropped");
|
|
1971
|
-
return open.length === 0 ? { satisfied: true } : {
|
|
1972
|
-
satisfied: false,
|
|
1973
|
-
reason: notYetTerminal("waypoint", open.map((w) => w.status), ["open", "claimed"])
|
|
1974
|
-
};
|
|
1975
|
-
}
|
|
1976
|
-
case "spec-file-exists":
|
|
1977
|
-
return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
|
|
1978
|
-
case "tickets-approved": {
|
|
1979
|
-
const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
|
|
1980
|
-
return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
|
|
1981
|
-
}
|
|
1982
|
-
case "all-tickets-terminal": {
|
|
1983
|
-
const tickets2 = listByFeature2(ctx, feature.id);
|
|
1984
|
-
if (tickets2.length === 0)
|
|
1985
|
-
return { satisfied: false, reason: "no tickets have run yet" };
|
|
1986
|
-
const open = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
|
|
1987
|
-
return open.length === 0 ? { satisfied: true } : {
|
|
1988
|
-
satisfied: false,
|
|
1989
|
-
reason: notYetTerminal("ticket", open.map((t) => t.status), ["pending", "burning"])
|
|
1990
|
-
};
|
|
2150
|
+
function runDriveHook(command, opts) {
|
|
2151
|
+
const spawnFn = opts.spawnFn ?? spawn2;
|
|
2152
|
+
const now = opts.now ?? Date.now;
|
|
2153
|
+
const timeoutMs = opts.timeoutMs ?? DRIVE_HOOK_TIMEOUT_MS;
|
|
2154
|
+
const startedAt = now();
|
|
2155
|
+
const { file, args, verbatim } = hookSpawnTarget(command);
|
|
2156
|
+
return new Promise((resolve5) => {
|
|
2157
|
+
let output = "";
|
|
2158
|
+
let timedOut = false;
|
|
2159
|
+
let settled = false;
|
|
2160
|
+
let timer;
|
|
2161
|
+
const finish = (exitCode) => {
|
|
2162
|
+
if (settled)
|
|
2163
|
+
return;
|
|
2164
|
+
settled = true;
|
|
2165
|
+
if (timer)
|
|
2166
|
+
clearTimeout(timer);
|
|
2167
|
+
resolve5({
|
|
2168
|
+
ok: !timedOut && exitCode === 0,
|
|
2169
|
+
exitCode,
|
|
2170
|
+
timedOut,
|
|
2171
|
+
output: tailLines(output),
|
|
2172
|
+
durationMs: now() - startedAt
|
|
2173
|
+
});
|
|
2174
|
+
};
|
|
2175
|
+
let child;
|
|
2176
|
+
try {
|
|
2177
|
+
child = spawnFn(file, args, {
|
|
2178
|
+
cwd: opts.cwd,
|
|
2179
|
+
env: opts.env ?? process.env,
|
|
2180
|
+
windowsHide: true,
|
|
2181
|
+
windowsVerbatimArguments: verbatim
|
|
2182
|
+
});
|
|
2183
|
+
} catch (err) {
|
|
2184
|
+
output = err instanceof Error ? err.message : String(err);
|
|
2185
|
+
finish(null);
|
|
2186
|
+
return;
|
|
1991
2187
|
}
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
ctx.db.insert(gateOverrides).values({ featureId, gate, reason, ts: Date.now() }).run();
|
|
2014
|
-
emit(ctx, featureId, {
|
|
2015
|
-
type: "gate.overridden",
|
|
2016
|
-
message: `gate ${gate} overridden: ${reason}`,
|
|
2017
|
-
data: { gate, reason }
|
|
2188
|
+
timer = setTimeout(() => {
|
|
2189
|
+
timedOut = true;
|
|
2190
|
+
child.kill();
|
|
2191
|
+
const grace = setTimeout(() => finish(null), KILL_GRACE_MS);
|
|
2192
|
+
grace.unref?.();
|
|
2193
|
+
}, timeoutMs);
|
|
2194
|
+
timer.unref?.();
|
|
2195
|
+
child.stdout?.on("data", (d) => {
|
|
2196
|
+
output += d.toString();
|
|
2197
|
+
});
|
|
2198
|
+
child.stderr?.on("data", (d) => {
|
|
2199
|
+
output += d.toString();
|
|
2200
|
+
});
|
|
2201
|
+
child.on("error", (err) => {
|
|
2202
|
+
output += `
|
|
2203
|
+
${err instanceof Error ? err.message : String(err)}`;
|
|
2204
|
+
finish(null);
|
|
2205
|
+
});
|
|
2206
|
+
child.on("close", (code) => {
|
|
2207
|
+
finish(code);
|
|
2208
|
+
});
|
|
2018
2209
|
});
|
|
2019
|
-
const next = nextPhase(feature);
|
|
2020
|
-
if (next)
|
|
2021
|
-
return setPhase(ctx, featureId, next, "phase.advanced", `advanced to ${next} (override)`);
|
|
2022
|
-
return feature;
|
|
2023
2210
|
}
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2211
|
+
function describeHookResult(command, result) {
|
|
2212
|
+
const secs = Math.max(1, Math.round(result.durationMs / 1000));
|
|
2213
|
+
if (result.timedOut)
|
|
2214
|
+
return `\`${command}\` timed out after ${secs}s`;
|
|
2215
|
+
if (result.ok)
|
|
2216
|
+
return `\`${command}\` finished in ${secs}s`;
|
|
2217
|
+
return `\`${command}\` exited ${result.exitCode ?? "without a code"} after ${secs}s`;
|
|
2218
|
+
}
|
|
2219
|
+
var DRIVE_HOOK_TIMEOUT_MS, OUTPUT_TAIL_LINES = 40, KILL_GRACE_MS = 2000;
|
|
2220
|
+
var init_drive_hooks = __esm(() => {
|
|
2221
|
+
DRIVE_HOOK_TIMEOUT_MS = 10 * 60000;
|
|
2032
2222
|
});
|
|
2033
2223
|
|
|
2034
|
-
// src/
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
const { ctx, featureId, repoPath, devCommand, onUrl } = input;
|
|
2050
|
-
const paneId = drivePaneId(featureId);
|
|
2051
|
-
const { file, args } = devSpawnTarget(devCommand);
|
|
2052
|
-
try {
|
|
2053
|
-
ptyRegistry().create({
|
|
2054
|
-
sessionId: paneId,
|
|
2055
|
-
cmd: file,
|
|
2056
|
-
args,
|
|
2057
|
-
opts: { cwd: repoPath, env: process.env, cols: 80, rows: 24, useConpty: true }
|
|
2058
|
-
});
|
|
2059
|
-
} catch (err) {
|
|
2060
|
-
emit(ctx, featureId, {
|
|
2061
|
-
type: "testdrive.dev_failed",
|
|
2062
|
-
message: `dev server failed to start: ${err instanceof Error ? err.message : String(err)}`,
|
|
2063
|
-
data: { paneId }
|
|
2064
|
-
});
|
|
2065
|
-
return;
|
|
2066
|
-
}
|
|
2067
|
-
let scanned = "";
|
|
2068
|
-
let found = false;
|
|
2069
|
-
const sniffer = {
|
|
2070
|
-
sendData(chunk) {
|
|
2071
|
-
if (found)
|
|
2072
|
-
return;
|
|
2073
|
-
scanned = (scanned + chunk.toString("utf8")).slice(-SNIFF_WINDOW);
|
|
2074
|
-
const url = sniffDevUrl(scanned);
|
|
2075
|
-
if (url) {
|
|
2076
|
-
found = true;
|
|
2077
|
-
onUrl(url);
|
|
2078
|
-
}
|
|
2079
|
-
},
|
|
2080
|
-
sendControl() {}
|
|
2224
|
+
// src/services/drive-env.ts
|
|
2225
|
+
import { createHash } from "crypto";
|
|
2226
|
+
function identifierSafe(slug, max = MAX_ID_LENGTH) {
|
|
2227
|
+
const cleaned = slug.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
2228
|
+
const safe = cleaned === "" || /^[0-9]/.test(cleaned) ? `x_${cleaned}` : cleaned;
|
|
2229
|
+
if (safe.length <= max)
|
|
2230
|
+
return safe;
|
|
2231
|
+
const digest = createHash("sha1").update(slug).digest("hex").slice(0, 6);
|
|
2232
|
+
return `${safe.slice(0, max - digest.length - 1)}_${digest}`;
|
|
2233
|
+
}
|
|
2234
|
+
function driveVars(identity) {
|
|
2235
|
+
return {
|
|
2236
|
+
slug: identity.slug,
|
|
2237
|
+
branch: identity.branch,
|
|
2238
|
+
id: identifierSafe(identity.slug)
|
|
2081
2239
|
};
|
|
2082
|
-
ptyRegistry().attach(paneId, sniffer);
|
|
2083
|
-
emit(ctx, featureId, {
|
|
2084
|
-
type: "testdrive.dev_started",
|
|
2085
|
-
message: `dev server spawned: ${devCommand}`,
|
|
2086
|
-
data: { paneId }
|
|
2087
|
-
});
|
|
2088
|
-
return paneId;
|
|
2089
|
-
}
|
|
2090
|
-
function stopDevPane(paneId) {
|
|
2091
|
-
const reg = ptyRegistry();
|
|
2092
|
-
const entry = reg.get(paneId);
|
|
2093
|
-
if (entry && !entry.exited && process.platform !== "win32") {
|
|
2094
|
-
try {
|
|
2095
|
-
process.kill(-entry.pty.pid, "SIGTERM");
|
|
2096
|
-
} catch {}
|
|
2097
|
-
}
|
|
2098
|
-
reg.kill(paneId);
|
|
2099
|
-
reg.remove(paneId);
|
|
2100
2240
|
}
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2241
|
+
function renderTemplate(value, vars) {
|
|
2242
|
+
const unknown2 = [];
|
|
2243
|
+
const out = value.replace(PLACEHOLDER_RE, (match, name) => {
|
|
2244
|
+
const replacement = vars[name];
|
|
2245
|
+
if (replacement === undefined) {
|
|
2246
|
+
if (!unknown2.includes(name))
|
|
2247
|
+
unknown2.push(name);
|
|
2248
|
+
return match;
|
|
2249
|
+
}
|
|
2250
|
+
return replacement;
|
|
2251
|
+
});
|
|
2252
|
+
return { value: out, unknown: unknown2 };
|
|
2253
|
+
}
|
|
2254
|
+
function parseDriveEnv(raw, identity) {
|
|
2255
|
+
const vars = {};
|
|
2256
|
+
const unknown2 = [];
|
|
2257
|
+
if (!raw?.trim())
|
|
2258
|
+
return { vars, unknown: unknown2 };
|
|
2259
|
+
const substitutions = driveVars(identity);
|
|
2260
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
2261
|
+
const trimmed = line.trim();
|
|
2262
|
+
if (trimmed === "" || trimmed.startsWith("#"))
|
|
2263
|
+
continue;
|
|
2264
|
+
const eq5 = trimmed.indexOf("=");
|
|
2265
|
+
if (eq5 <= 0)
|
|
2266
|
+
continue;
|
|
2267
|
+
const key = trimmed.slice(0, eq5).trim().replace(/^export\s+/, "");
|
|
2268
|
+
if (key === "")
|
|
2269
|
+
continue;
|
|
2270
|
+
const rendered = renderTemplate(unquote(trimmed.slice(eq5 + 1).trim()), substitutions);
|
|
2271
|
+
vars[key] = rendered.value;
|
|
2272
|
+
for (const name of rendered.unknown)
|
|
2273
|
+
if (!unknown2.includes(name))
|
|
2274
|
+
unknown2.push(name);
|
|
2275
|
+
}
|
|
2276
|
+
return { vars, unknown: unknown2 };
|
|
2277
|
+
}
|
|
2278
|
+
function unquote(value) {
|
|
2279
|
+
if (value.length < 2)
|
|
2280
|
+
return value;
|
|
2281
|
+
const first = value[0];
|
|
2282
|
+
if ((first === '"' || first === "'") && value.endsWith(first))
|
|
2283
|
+
return value.slice(1, -1);
|
|
2284
|
+
return value;
|
|
2285
|
+
}
|
|
2286
|
+
function driveProcessEnv(overrides, base = process.env) {
|
|
2287
|
+
return { ...base, ...overrides };
|
|
2288
|
+
}
|
|
2289
|
+
function describeDriveEnv(vars) {
|
|
2290
|
+
const keys = Object.keys(vars);
|
|
2291
|
+
return `drive environment: ${keys.join(", ")}`;
|
|
2292
|
+
}
|
|
2293
|
+
var MAX_ID_LENGTH = 40, PLACEHOLDER_RE;
|
|
2294
|
+
var init_drive_env = __esm(() => {
|
|
2295
|
+
PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
|
|
2105
2296
|
});
|
|
2106
2297
|
|
|
2107
2298
|
// src/services/git.ts
|
|
2108
|
-
import { existsSync as
|
|
2299
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
|
|
2109
2300
|
import { tmpdir } from "os";
|
|
2110
|
-
import { dirname, join as join6, resolve as
|
|
2301
|
+
import { dirname as dirname2, join as join6, resolve as resolve5 } from "path";
|
|
2111
2302
|
import { simpleGit } from "simple-git";
|
|
2303
|
+
function migrationPaths(paths) {
|
|
2304
|
+
return paths.filter((p) => MIGRATION_DIR_RE.test(p) || DRIZZLE_SQL_RE.test(p));
|
|
2305
|
+
}
|
|
2112
2306
|
function featureBranch(slug) {
|
|
2113
2307
|
return `feature/${slug}`;
|
|
2114
2308
|
}
|
|
@@ -2119,14 +2313,14 @@ function errMsg(e) {
|
|
|
2119
2313
|
return e instanceof Error ? e.message : String(e);
|
|
2120
2314
|
}
|
|
2121
2315
|
function canon(p) {
|
|
2122
|
-
let abs =
|
|
2316
|
+
let abs = resolve5(p);
|
|
2123
2317
|
try {
|
|
2124
2318
|
abs = realpathSync.native(abs);
|
|
2125
2319
|
} catch {}
|
|
2126
2320
|
return process.platform === "win32" ? abs.replace(/\\/g, "/").toLowerCase() : abs;
|
|
2127
2321
|
}
|
|
2128
2322
|
async function assertRepo(repoPath) {
|
|
2129
|
-
if (!
|
|
2323
|
+
if (!existsSync6(repoPath)) {
|
|
2130
2324
|
throw new InvalidInputError(`path does not exist: ${repoPath}`);
|
|
2131
2325
|
}
|
|
2132
2326
|
let isRepo = false;
|
|
@@ -2215,7 +2409,7 @@ async function ensureTalkWorktree(project, feature) {
|
|
|
2215
2409
|
await ensureBranchExists(g, branch, feature.baseBranch ?? project.mainBranch);
|
|
2216
2410
|
if (await worktreeIsValid(g, worktreePath, branch))
|
|
2217
2411
|
return worktreePath;
|
|
2218
|
-
mkdirSync2(
|
|
2412
|
+
mkdirSync2(dirname2(worktreePath), { recursive: true });
|
|
2219
2413
|
try {
|
|
2220
2414
|
await g.raw(["worktree", "add", worktreePath, branch]);
|
|
2221
2415
|
return worktreePath;
|
|
@@ -2238,7 +2432,7 @@ async function ensureBranchExists(g, branch, from) {
|
|
|
2238
2432
|
}
|
|
2239
2433
|
}
|
|
2240
2434
|
async function worktreeIsValid(g, worktreePath, branch) {
|
|
2241
|
-
if (!
|
|
2435
|
+
if (!existsSync6(worktreePath))
|
|
2242
2436
|
return false;
|
|
2243
2437
|
const registered = await registeredWorktrees(g);
|
|
2244
2438
|
if (!registered.has(canon(worktreePath)))
|
|
@@ -2284,7 +2478,7 @@ async function worktreesOnBranch(g, branch, mainRepoPath) {
|
|
|
2284
2478
|
return (await listWorktrees(g)).filter((e) => e.branch === branch && canon(e.path) !== main).map((e) => e.path);
|
|
2285
2479
|
}
|
|
2286
2480
|
async function detachWorktree(path) {
|
|
2287
|
-
if (!
|
|
2481
|
+
if (!existsSync6(path))
|
|
2288
2482
|
return false;
|
|
2289
2483
|
const g = git(path);
|
|
2290
2484
|
let head;
|
|
@@ -2299,12 +2493,27 @@ async function detachWorktree(path) {
|
|
|
2299
2493
|
return true;
|
|
2300
2494
|
}
|
|
2301
2495
|
async function reattachWorktree(path, branch) {
|
|
2302
|
-
if (!
|
|
2496
|
+
if (!existsSync6(path))
|
|
2303
2497
|
return;
|
|
2304
2498
|
try {
|
|
2305
2499
|
await git(path).checkout(branch);
|
|
2306
2500
|
} catch {}
|
|
2307
2501
|
}
|
|
2502
|
+
function prepBranchName(unique) {
|
|
2503
|
+
return `${PREP_BRANCH_PREFIX}${unique}`;
|
|
2504
|
+
}
|
|
2505
|
+
async function readFileAtRef(repoPath, ref, path) {
|
|
2506
|
+
try {
|
|
2507
|
+
return await git(repoPath).raw(["show", `${ref}:${path}`]);
|
|
2508
|
+
} catch {
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
async function deletePrepBranch(repoPath, branch) {
|
|
2513
|
+
try {
|
|
2514
|
+
await deleteBranchDetachingWorktrees(git(repoPath), repoPath, branch);
|
|
2515
|
+
} catch {}
|
|
2516
|
+
}
|
|
2308
2517
|
function tempBranchSlugSegment(slug) {
|
|
2309
2518
|
return slug.slice(0, TEMP_BRANCH_SLUG_MAX).replace(/-+$/, "");
|
|
2310
2519
|
}
|
|
@@ -2322,14 +2531,14 @@ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
|
|
|
2322
2531
|
const attempts = Math.max(1, opts.attempts ?? 3);
|
|
2323
2532
|
const delayMs = opts.delayMs ?? 750;
|
|
2324
2533
|
const g = git(repoPath);
|
|
2325
|
-
for (let attempt = 1;attempt <= attempts &&
|
|
2534
|
+
for (let attempt = 1;attempt <= attempts && existsSync6(path); attempt++) {
|
|
2326
2535
|
try {
|
|
2327
2536
|
await g.raw(["worktree", "remove", "--force", path]);
|
|
2328
2537
|
} catch {}
|
|
2329
|
-
if (
|
|
2538
|
+
if (existsSync6(path) && attempt < attempts)
|
|
2330
2539
|
await sleep(delayMs);
|
|
2331
2540
|
}
|
|
2332
|
-
if (
|
|
2541
|
+
if (existsSync6(path)) {
|
|
2333
2542
|
try {
|
|
2334
2543
|
rmSync(path, { recursive: true, force: true });
|
|
2335
2544
|
} catch {}
|
|
@@ -2337,7 +2546,7 @@ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
|
|
|
2337
2546
|
try {
|
|
2338
2547
|
await g.raw(["worktree", "prune"]);
|
|
2339
2548
|
} catch {}
|
|
2340
|
-
return !
|
|
2549
|
+
return !existsSync6(path);
|
|
2341
2550
|
}
|
|
2342
2551
|
function sleep(ms) {
|
|
2343
2552
|
return new Promise((done) => setTimeout(done, ms));
|
|
@@ -2351,16 +2560,42 @@ async function branchCommitsAhead(repoPath, base, branch) {
|
|
|
2351
2560
|
return [];
|
|
2352
2561
|
}
|
|
2353
2562
|
}
|
|
2354
|
-
async function
|
|
2563
|
+
async function headSha(repoPath, ref) {
|
|
2355
2564
|
try {
|
|
2356
|
-
const out = await git(repoPath).
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2565
|
+
const out = (await git(repoPath).revparse([ref])).trim();
|
|
2566
|
+
return out.length > 0 ? out : undefined;
|
|
2567
|
+
} catch {
|
|
2568
|
+
return;
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
async function commitsSince(repoPath, sha, branch) {
|
|
2572
|
+
try {
|
|
2573
|
+
const out = (await git(repoPath).raw(["rev-list", "--count", `${sha}..${branch}`])).trim();
|
|
2574
|
+
const n = Number(out);
|
|
2575
|
+
return Number.isFinite(n) ? n : undefined;
|
|
2576
|
+
} catch {
|
|
2577
|
+
return;
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
async function diffPaths(repoPath, from, to) {
|
|
2581
|
+
try {
|
|
2582
|
+
const out = (await git(repoPath).raw(["diff", "--name-only", from, to])).trim();
|
|
2583
|
+
return out ? out.split(`
|
|
2584
|
+
`).map((s) => s.trim()).filter(Boolean) : [];
|
|
2585
|
+
} catch {
|
|
2586
|
+
return [];
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
async function commitSummaries(repoPath, base, branch, limit = 20) {
|
|
2590
|
+
try {
|
|
2591
|
+
const out = await git(repoPath).raw([
|
|
2592
|
+
"log",
|
|
2593
|
+
`--max-count=${limit}`,
|
|
2594
|
+
"--format=%h %s",
|
|
2595
|
+
`${base}..${branch}`
|
|
2596
|
+
]);
|
|
2597
|
+
return out.split(`
|
|
2598
|
+
`).map((l) => l.trim()).filter(Boolean);
|
|
2364
2599
|
} catch {
|
|
2365
2600
|
return [];
|
|
2366
2601
|
}
|
|
@@ -2546,7 +2781,7 @@ async function removeTalkWorktree(repoPath, worktreePath) {
|
|
|
2546
2781
|
try {
|
|
2547
2782
|
await g.raw(["worktree", "prune"]);
|
|
2548
2783
|
} catch {}
|
|
2549
|
-
if (
|
|
2784
|
+
if (existsSync6(worktreePath)) {
|
|
2550
2785
|
throw new InvalidInputError(`could not remove talk worktree at ${worktreePath} \u2014 a file may be locked; ` + "close anything using it and retry the delete");
|
|
2551
2786
|
}
|
|
2552
2787
|
}
|
|
@@ -2573,7 +2808,7 @@ async function deleteFeatureBranches(repoPath, slug) {
|
|
|
2573
2808
|
return { deleted, kept };
|
|
2574
2809
|
}
|
|
2575
2810
|
async function commitDocs(worktreePath, message) {
|
|
2576
|
-
if (!
|
|
2811
|
+
if (!existsSync6(resolve5(worktreePath, DOCS_PATHSPEC)))
|
|
2577
2812
|
return;
|
|
2578
2813
|
const g = git(worktreePath);
|
|
2579
2814
|
await g.add([DOCS_PATHSPEC]);
|
|
@@ -2618,6 +2853,8 @@ async function testDrive(ctx, project, feature, action) {
|
|
|
2618
2853
|
const devPaneId = testDriveState.devPaneId;
|
|
2619
2854
|
if (devPaneId)
|
|
2620
2855
|
stopDevPane(devPaneId);
|
|
2856
|
+
const teardownFailure = await runDriveHookStep(ctx, feature.id, project.repoPath, "teardown", project.driveStopCommand, driveEnvFor(ctx, project, feature, branch));
|
|
2857
|
+
const carriedChanges = await dirtyPaths(g);
|
|
2621
2858
|
await g.checkout(previousBranch2);
|
|
2622
2859
|
if (detachedWorktree2)
|
|
2623
2860
|
await reattachWorktree(detachedWorktree2, branch);
|
|
@@ -2627,7 +2864,21 @@ async function testDrive(ctx, project, feature, action) {
|
|
|
2627
2864
|
message: `test drive stopped \u2014 back on ${previousBranch2}`,
|
|
2628
2865
|
data: { branch: previousBranch2 }
|
|
2629
2866
|
});
|
|
2630
|
-
|
|
2867
|
+
if (carriedChanges.length > 0) {
|
|
2868
|
+
emit(ctx, feature.id, {
|
|
2869
|
+
type: "testdrive.carried_changes",
|
|
2870
|
+
message: `${carriedChanges.length} uncommitted file(s) came back with you onto ${previousBranch2}: ${carriedChanges.slice(0, 5).join(", ")}${carriedChanges.length > 5 ? ", \u2026" : ""}`,
|
|
2871
|
+
data: { branch: previousBranch2, files: carriedChanges }
|
|
2872
|
+
});
|
|
2873
|
+
}
|
|
2874
|
+
const dbDrift = await detectDbDrift(ctx, project, feature, previousBranch2, branch);
|
|
2875
|
+
return {
|
|
2876
|
+
ok: true,
|
|
2877
|
+
branch: previousBranch2,
|
|
2878
|
+
...carriedChanges.length > 0 ? { carriedChanges } : {},
|
|
2879
|
+
...dbDrift ? { dbDrift } : {},
|
|
2880
|
+
...teardownFailure ? { hookFailure: teardownFailure } : {}
|
|
2881
|
+
};
|
|
2631
2882
|
}
|
|
2632
2883
|
const porcelain = (await g.raw(["status", "--porcelain"])).trim();
|
|
2633
2884
|
if (porcelain !== "")
|
|
@@ -2651,18 +2902,105 @@ async function testDrive(ctx, project, feature, action) {
|
|
|
2651
2902
|
message: `test driving ${branch} (was on ${previousBranch})`,
|
|
2652
2903
|
data: { branch, previousBranch }
|
|
2653
2904
|
});
|
|
2905
|
+
const driveEnv = driveEnvFor(ctx, project, feature, branch);
|
|
2906
|
+
const setupFailure = await runDriveHookStep(ctx, feature.id, project.repoPath, "setup", project.driveSetupCommand, driveEnv);
|
|
2654
2907
|
if (project.devCommand) {
|
|
2655
2908
|
const devPaneId = startDevPane({
|
|
2656
2909
|
ctx,
|
|
2657
2910
|
featureId: feature.id,
|
|
2658
2911
|
repoPath: project.repoPath,
|
|
2659
2912
|
devCommand: project.devCommand,
|
|
2913
|
+
env: driveEnv,
|
|
2660
2914
|
onUrl: (url) => recordDriveUrl(ctx, feature.id, url)
|
|
2661
2915
|
});
|
|
2662
2916
|
if (devPaneId)
|
|
2663
2917
|
testDriveState.devPaneId = devPaneId;
|
|
2664
2918
|
}
|
|
2665
|
-
return { ok: true, branch };
|
|
2919
|
+
return { ok: true, branch, ...setupFailure ? { hookFailure: setupFailure } : {} };
|
|
2920
|
+
}
|
|
2921
|
+
function driveEnvFor(ctx, project, feature, branch) {
|
|
2922
|
+
const { vars, unknown: unknown2 } = parseDriveEnv(project.driveEnv, { slug: feature.slug, branch });
|
|
2923
|
+
if (unknown2.length > 0) {
|
|
2924
|
+
emit(ctx, feature.id, {
|
|
2925
|
+
type: "testdrive.env_unknown_placeholder",
|
|
2926
|
+
message: `drive environment left ${unknown2.map((u) => `{{${u}}}`).join(", ")} unsubstituted \u2014 known variables are {{slug}}, {{branch}}, {{id}}`,
|
|
2927
|
+
data: { unknown: unknown2 }
|
|
2928
|
+
});
|
|
2929
|
+
}
|
|
2930
|
+
if (Object.keys(vars).length > 0) {
|
|
2931
|
+
emit(ctx, feature.id, {
|
|
2932
|
+
type: "testdrive.env",
|
|
2933
|
+
message: describeDriveEnv(vars),
|
|
2934
|
+
data: { keys: Object.keys(vars) }
|
|
2935
|
+
});
|
|
2936
|
+
}
|
|
2937
|
+
return driveProcessEnv(vars);
|
|
2938
|
+
}
|
|
2939
|
+
async function runDriveHookStep(ctx, featureId, repoPath, phase, command, env) {
|
|
2940
|
+
const cmd = command?.trim();
|
|
2941
|
+
if (!cmd)
|
|
2942
|
+
return;
|
|
2943
|
+
emit(ctx, featureId, {
|
|
2944
|
+
type: `testdrive.${phase}_started`,
|
|
2945
|
+
message: phase === "setup" ? `preparing environment: \`${cmd}\`` : `tearing down environment: \`${cmd}\``,
|
|
2946
|
+
data: { command: cmd }
|
|
2947
|
+
});
|
|
2948
|
+
const result = await runDriveHook(cmd, { cwd: repoPath, env });
|
|
2949
|
+
if (result.ok) {
|
|
2950
|
+
emit(ctx, featureId, {
|
|
2951
|
+
type: `testdrive.${phase}_ok`,
|
|
2952
|
+
message: describeHookResult(cmd, result),
|
|
2953
|
+
data: { command: cmd, durationMs: result.durationMs }
|
|
2954
|
+
});
|
|
2955
|
+
return;
|
|
2956
|
+
}
|
|
2957
|
+
emit(ctx, featureId, {
|
|
2958
|
+
type: `testdrive.${phase}_failed`,
|
|
2959
|
+
message: `${describeHookResult(cmd, result)}${result.output ? `
|
|
2960
|
+
${result.output}` : ""}`,
|
|
2961
|
+
data: {
|
|
2962
|
+
command: cmd,
|
|
2963
|
+
exitCode: result.exitCode,
|
|
2964
|
+
timedOut: result.timedOut,
|
|
2965
|
+
output: result.output
|
|
2966
|
+
}
|
|
2967
|
+
});
|
|
2968
|
+
return {
|
|
2969
|
+
phase,
|
|
2970
|
+
command: cmd,
|
|
2971
|
+
exitCode: result.exitCode,
|
|
2972
|
+
timedOut: result.timedOut,
|
|
2973
|
+
output: result.output
|
|
2974
|
+
};
|
|
2975
|
+
}
|
|
2976
|
+
async function dirtyPaths(g) {
|
|
2977
|
+
try {
|
|
2978
|
+
const out = (await g.raw(["status", "--porcelain"])).trim();
|
|
2979
|
+
if (!out)
|
|
2980
|
+
return [];
|
|
2981
|
+
return out.split(`
|
|
2982
|
+
`).map((line) => line.slice(3).trim()).map((p) => p.includes(" -> ") ? p.split(" -> ").at(-1) ?? p : p).map((p) => p.replace(/^"|"$/g, "")).filter(Boolean);
|
|
2983
|
+
} catch {
|
|
2984
|
+
return [];
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
async function detectDbDrift(ctx, project, feature, previousBranch, driveBranch) {
|
|
2988
|
+
let files;
|
|
2989
|
+
try {
|
|
2990
|
+
files = migrationPaths(await diffPaths(project.repoPath, previousBranch, driveBranch));
|
|
2991
|
+
} catch {
|
|
2992
|
+
return;
|
|
2993
|
+
}
|
|
2994
|
+
if (files.length === 0)
|
|
2995
|
+
return;
|
|
2996
|
+
const resetCommand = project.dbResetCommand?.trim() || undefined;
|
|
2997
|
+
const drift = { files, ...resetCommand ? { resetCommand } : {} };
|
|
2998
|
+
emit(ctx, feature.id, {
|
|
2999
|
+
type: "testdrive.db_drift",
|
|
3000
|
+
message: `${driveBranch} and ${previousBranch} differ by ${files.length} migration file(s) \u2014 ` + "anything you migrated during the drive is still applied to your dev database, so the next " + `migrate on ${previousBranch} may report drift. ` + (resetCommand ? `Rebuild it with: ${resetCommand}` : 'Set a "Database reset command" in project settings to get a one-click fix here.'),
|
|
3001
|
+
data: { files, previousBranch, driveBranch, resetCommand: resetCommand ?? null }
|
|
3002
|
+
});
|
|
3003
|
+
return drift;
|
|
2666
3004
|
}
|
|
2667
3005
|
async function mergeFeature(project, feature) {
|
|
2668
3006
|
const target = feature.baseBranch ?? project.mainBranch;
|
|
@@ -2719,171 +3057,20 @@ async function mergeInProgress(g) {
|
|
|
2719
3057
|
return false;
|
|
2720
3058
|
}
|
|
2721
3059
|
}
|
|
2722
|
-
var DOCS_PATHSPEC = "docs/features", DENY_DIRTY = "Working tree has uncommitted changes \u2014 commit or stash first", DENY_ACTIVE = "A test drive is already active \u2014 stop it first", DENY_ACTIVE_RUN = "Feature has an active run \u2014 wait for it to finish", DENY_NONE_ACTIVE = "No test drive is active", RESEARCH_BRANCH_PREFIX = "runcastle/research/", TICKET_BRANCH_PREFIX = "runcastle/ticket/", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, testDriveState;
|
|
3060
|
+
var DOCS_PATHSPEC = "docs/features", MIGRATION_DIR_RE, DRIZZLE_SQL_RE, DENY_DIRTY = "Working tree has uncommitted changes \u2014 commit or stash first", DENY_ACTIVE = "A test drive is already active \u2014 stop it first", DENY_ACTIVE_RUN = "Feature has an active run \u2014 wait for it to finish", DENY_NONE_ACTIVE = "No test drive is active", RESEARCH_BRANCH_PREFIX = "runcastle/research/", TICKET_BRANCH_PREFIX = "runcastle/ticket/", PREP_BRANCH_PREFIX = "runcastle/prep/", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, testDriveState;
|
|
2723
3061
|
var init_git = __esm(() => {
|
|
2724
3062
|
init_paths();
|
|
2725
3063
|
init_errors();
|
|
2726
3064
|
init_dev_pane();
|
|
3065
|
+
init_drive_hooks();
|
|
3066
|
+
init_drive_env();
|
|
2727
3067
|
init_events();
|
|
2728
3068
|
init_repo();
|
|
3069
|
+
MIGRATION_DIR_RE = /(^|\/)(migrations|migrate)\//i;
|
|
3070
|
+
DRIZZLE_SQL_RE = /(^|\/)drizzle\/.*\.sql$/i;
|
|
2729
3071
|
TEMP_BRANCH_PREFIXES = [RESEARCH_BRANCH_PREFIX, TICKET_BRANCH_PREFIX];
|
|
2730
3072
|
});
|
|
2731
3073
|
|
|
2732
|
-
// src/services/knowledge.ts
|
|
2733
|
-
import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync } from "fs";
|
|
2734
|
-
import { join as join7, relative, resolve as resolve4, sep } from "path";
|
|
2735
|
-
function scaffoldDocs(ctx, feature) {
|
|
2736
|
-
const project = projectForFeature(ctx, feature);
|
|
2737
|
-
const dir = featureDocsDir(project, feature);
|
|
2738
|
-
mkdirSync3(dir, { recursive: true });
|
|
2739
|
-
const briefPath = featureDocPath(project, feature, "brief.md");
|
|
2740
|
-
if (!existsSync8(briefPath)) {
|
|
2741
|
-
const created = new Date().toISOString();
|
|
2742
|
-
const brief = [
|
|
2743
|
-
`# ${feature.title}`,
|
|
2744
|
-
"",
|
|
2745
|
-
feature.oneLiner,
|
|
2746
|
-
"",
|
|
2747
|
-
`- Slug: ${feature.slug}`,
|
|
2748
|
-
`- Created: ${created}`,
|
|
2749
|
-
""
|
|
2750
|
-
].join(`
|
|
2751
|
-
`);
|
|
2752
|
-
writeFileSync(briefPath, brief, "utf8");
|
|
2753
|
-
}
|
|
2754
|
-
if (feature.mapped) {
|
|
2755
|
-
scaffoldMapDoc(project, feature);
|
|
2756
|
-
}
|
|
2757
|
-
emit(ctx, feature.id, {
|
|
2758
|
-
type: "docs.scaffolded",
|
|
2759
|
-
message: feature.mapped ? `scaffolded docs/features/${feature.slug}/{brief,map}.md` : `scaffolded docs/features/${feature.slug}/brief.md`
|
|
2760
|
-
});
|
|
2761
|
-
}
|
|
2762
|
-
function scaffoldMapDoc(project, feature, seed) {
|
|
2763
|
-
mkdirSync3(featureDocsDir(project, feature), { recursive: true });
|
|
2764
|
-
const mapPath = featureDocPath(project, feature, "map.md");
|
|
2765
|
-
if (existsSync8(mapPath))
|
|
2766
|
-
return;
|
|
2767
|
-
writeFileSync(mapPath, mapDocBody(feature, seed), "utf8");
|
|
2768
|
-
}
|
|
2769
|
-
function mapDocBody(feature, seed) {
|
|
2770
|
-
const seeded = {
|
|
2771
|
-
Destination: seed?.destination?.trim(),
|
|
2772
|
-
Notes: seed?.notes?.trim()
|
|
2773
|
-
};
|
|
2774
|
-
const lines = [`# ${feature.title} \u2014 map`, ""];
|
|
2775
|
-
for (const section of MAP_SECTIONS) {
|
|
2776
|
-
lines.push(`## ${section}`, "");
|
|
2777
|
-
const body = seeded[section];
|
|
2778
|
-
if (body)
|
|
2779
|
-
lines.push(body, "");
|
|
2780
|
-
}
|
|
2781
|
-
return lines.join(`
|
|
2782
|
-
`);
|
|
2783
|
-
}
|
|
2784
|
-
function listDocs(ctx, feature) {
|
|
2785
|
-
const project = projectForFeature(ctx, feature);
|
|
2786
|
-
const dir = featureDocsDir(project, feature);
|
|
2787
|
-
if (!existsSync8(dir))
|
|
2788
|
-
return [];
|
|
2789
|
-
return readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => ({ relPath: e.name, title: readTitle(join7(dir, e.name), e.name) })).sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
2790
|
-
}
|
|
2791
|
-
function readDoc(ctx, feature, relPath) {
|
|
2792
|
-
const project = projectForFeature(ctx, feature);
|
|
2793
|
-
const dir = featureDocsDir(project, feature);
|
|
2794
|
-
const root = resolve4(dir);
|
|
2795
|
-
const target = resolve4(dir, relPath);
|
|
2796
|
-
const rel = relative(root, target);
|
|
2797
|
-
if (rel === "" || rel.startsWith("..") || rel.split(sep).includes("..")) {
|
|
2798
|
-
throw new InvalidInputError(`doc path escapes the feature docs dir: ${relPath}`);
|
|
2799
|
-
}
|
|
2800
|
-
if (!existsSync8(target))
|
|
2801
|
-
throw new NotFoundError(`doc not found: ${relPath}`);
|
|
2802
|
-
return { content: readFileSync3(target, "utf8") };
|
|
2803
|
-
}
|
|
2804
|
-
function readTitle(path, fallback) {
|
|
2805
|
-
try {
|
|
2806
|
-
const firstLine = readFileSync3(path, "utf8").split(`
|
|
2807
|
-
`, 1)[0] ?? "";
|
|
2808
|
-
const heading = firstLine.match(/^#\s+(.+)$/);
|
|
2809
|
-
return heading ? heading[1].trim() : fallback;
|
|
2810
|
-
} catch {
|
|
2811
|
-
return fallback;
|
|
2812
|
-
}
|
|
2813
|
-
}
|
|
2814
|
-
var MAP_SECTIONS;
|
|
2815
|
-
var init_knowledge = __esm(() => {
|
|
2816
|
-
init_errors();
|
|
2817
|
-
init_events();
|
|
2818
|
-
init_feature_docs();
|
|
2819
|
-
init_repo();
|
|
2820
|
-
MAP_SECTIONS = [
|
|
2821
|
-
"Destination",
|
|
2822
|
-
"Notes",
|
|
2823
|
-
"Not yet specified",
|
|
2824
|
-
"Out of scope"
|
|
2825
|
-
];
|
|
2826
|
-
});
|
|
2827
|
-
|
|
2828
|
-
// src/pty/end-session.ts
|
|
2829
|
-
function endSession(ctx, sessionId) {
|
|
2830
|
-
const registry = ptyRegistry();
|
|
2831
|
-
const killed = registry.kill(sessionId);
|
|
2832
|
-
registry.remove(sessionId);
|
|
2833
|
-
const session = markSessionEnded(ctx, sessionId);
|
|
2834
|
-
if (session) {
|
|
2835
|
-
releaseForSession(ctx, sessionId);
|
|
2836
|
-
emit(ctx, session.featureId, {
|
|
2837
|
-
type: "session.ended",
|
|
2838
|
-
message: "session ended by user",
|
|
2839
|
-
data: { sessionId, killed }
|
|
2840
|
-
});
|
|
2841
|
-
} else {
|
|
2842
|
-
const existing = getSessionRow(ctx, sessionId);
|
|
2843
|
-
if (!existing)
|
|
2844
|
-
return { sessionId, ok: false, killed };
|
|
2845
|
-
}
|
|
2846
|
-
return { sessionId, ok: true, killed };
|
|
2847
|
-
}
|
|
2848
|
-
var init_end_session = __esm(() => {
|
|
2849
|
-
init_sessions();
|
|
2850
|
-
init_events();
|
|
2851
|
-
init_waypoints();
|
|
2852
|
-
init_registry();
|
|
2853
|
-
});
|
|
2854
|
-
|
|
2855
|
-
// src/launcher/skills-root.ts
|
|
2856
|
-
import { existsSync as existsSync9 } from "fs";
|
|
2857
|
-
import { dirname as dirname2, join as join8, resolve as resolve5 } from "path";
|
|
2858
|
-
function resolveSkillsRoot(fromDir) {
|
|
2859
|
-
const override = process.env[SKILLS_DIR_ENV];
|
|
2860
|
-
if (override) {
|
|
2861
|
-
const root = resolve5(override);
|
|
2862
|
-
if (existsSync9(join8(root, SKILLS_MARKER)))
|
|
2863
|
-
return root;
|
|
2864
|
-
throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
|
|
2865
|
-
}
|
|
2866
|
-
const searched = [];
|
|
2867
|
-
let dir = fromDir;
|
|
2868
|
-
for (let i = 0;i < 8; i += 1) {
|
|
2869
|
-
const candidate = join8(dir, "packages", "skills");
|
|
2870
|
-
searched.push(candidate);
|
|
2871
|
-
if (existsSync9(join8(candidate, SKILLS_MARKER)))
|
|
2872
|
-
return candidate;
|
|
2873
|
-
const parent = dirname2(dir);
|
|
2874
|
-
if (parent === dir)
|
|
2875
|
-
break;
|
|
2876
|
-
dir = parent;
|
|
2877
|
-
}
|
|
2878
|
-
throw new Error(`runcastle skills root not found; searched:
|
|
2879
|
-
${searched.join(`
|
|
2880
|
-
`)}`);
|
|
2881
|
-
}
|
|
2882
|
-
var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
|
|
2883
|
-
var init_skills_root = __esm(() => {
|
|
2884
|
-
SKILLS_MARKER = join8("packs", "runcastle");
|
|
2885
|
-
});
|
|
2886
|
-
|
|
2887
3074
|
// src/services/agent-stream.ts
|
|
2888
3075
|
function beginTranscript(ticketId) {
|
|
2889
3076
|
transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
|
|
@@ -3012,8 +3199,8 @@ var init_burn_guard = __esm(() => {
|
|
|
3012
3199
|
});
|
|
3013
3200
|
|
|
3014
3201
|
// src/workflows/ticket-burner.ts
|
|
3015
|
-
import { existsSync as
|
|
3016
|
-
import { dirname as dirname3, join as
|
|
3202
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
|
|
3203
|
+
import { dirname as dirname3, join as join7 } from "path";
|
|
3017
3204
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
3018
3205
|
import { claudeCode, run } from "@ai-hero/sandcastle";
|
|
3019
3206
|
import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
|
|
@@ -3062,7 +3249,7 @@ function detectCycle(tickets2) {
|
|
|
3062
3249
|
}
|
|
3063
3250
|
return null;
|
|
3064
3251
|
}
|
|
3065
|
-
function
|
|
3252
|
+
function renderTemplate2(template, values) {
|
|
3066
3253
|
let out = template;
|
|
3067
3254
|
for (const [key, value] of Object.entries(values)) {
|
|
3068
3255
|
out = out.split(`{{${key}}}`).join(value);
|
|
@@ -3070,7 +3257,7 @@ function renderTemplate(template, values) {
|
|
|
3070
3257
|
return out;
|
|
3071
3258
|
}
|
|
3072
3259
|
function renderTicketPrompt(template, values) {
|
|
3073
|
-
return
|
|
3260
|
+
return renderTemplate2(template, values);
|
|
3074
3261
|
}
|
|
3075
3262
|
function buildTicketJson(ticket) {
|
|
3076
3263
|
return JSON.stringify({
|
|
@@ -3129,13 +3316,13 @@ function parseEnvFile(content) {
|
|
|
3129
3316
|
if (line.length === 0 || line.startsWith("#"))
|
|
3130
3317
|
continue;
|
|
3131
3318
|
const body = line.startsWith("export ") ? line.slice(7).trim() : line;
|
|
3132
|
-
const
|
|
3133
|
-
if (
|
|
3319
|
+
const eq5 = body.indexOf("=");
|
|
3320
|
+
if (eq5 === -1)
|
|
3134
3321
|
continue;
|
|
3135
|
-
const key = body.slice(0,
|
|
3322
|
+
const key = body.slice(0, eq5).trim();
|
|
3136
3323
|
if (key.length === 0)
|
|
3137
3324
|
continue;
|
|
3138
|
-
let value = body.slice(
|
|
3325
|
+
let value = body.slice(eq5 + 1).trim();
|
|
3139
3326
|
if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
|
|
3140
3327
|
value = value.slice(1, -1);
|
|
3141
3328
|
}
|
|
@@ -3158,6 +3345,9 @@ function detectPackageManager(tc) {
|
|
|
3158
3345
|
return "npm";
|
|
3159
3346
|
return tc.hasPackageJson ? "npm" : undefined;
|
|
3160
3347
|
}
|
|
3348
|
+
function orFallback(strict, permissive) {
|
|
3349
|
+
return `( ${strict} || ${permissive} )`;
|
|
3350
|
+
}
|
|
3161
3351
|
function resolveSetupCommand(tc, override) {
|
|
3162
3352
|
const trimmed = override?.trim();
|
|
3163
3353
|
if (trimmed)
|
|
@@ -3167,13 +3357,13 @@ function resolveSetupCommand(tc, override) {
|
|
|
3167
3357
|
return;
|
|
3168
3358
|
switch (pm) {
|
|
3169
3359
|
case "bun":
|
|
3170
|
-
return tc.lockfiles.bun ? "bun install --frozen-lockfile" : "bun install";
|
|
3360
|
+
return tc.lockfiles.bun ? orFallback("bun install --frozen-lockfile", "bun install") : "bun install";
|
|
3171
3361
|
case "pnpm":
|
|
3172
|
-
return tc.lockfiles.pnpm ? "corepack pnpm install --frozen-lockfile" : "corepack pnpm install";
|
|
3362
|
+
return tc.lockfiles.pnpm ? orFallback("corepack pnpm install --frozen-lockfile", "corepack pnpm install") : "corepack pnpm install";
|
|
3173
3363
|
case "yarn":
|
|
3174
|
-
return tc.lockfiles.yarn ? "corepack yarn install --frozen-lockfile" : "corepack yarn install";
|
|
3364
|
+
return tc.lockfiles.yarn ? orFallback("corepack yarn install --frozen-lockfile", "corepack yarn install") : "corepack yarn install";
|
|
3175
3365
|
case "npm":
|
|
3176
|
-
return tc.lockfiles.npm ? "npm ci" : "npm install";
|
|
3366
|
+
return tc.lockfiles.npm ? orFallback("npm ci", "npm install") : "npm install";
|
|
3177
3367
|
}
|
|
3178
3368
|
}
|
|
3179
3369
|
function cacheMountFor(pm, hostPath) {
|
|
@@ -3236,12 +3426,12 @@ function buildVerifyNotes(config2) {
|
|
|
3236
3426
|
`);
|
|
3237
3427
|
}
|
|
3238
3428
|
function readRepoToolchain(repoPath) {
|
|
3239
|
-
const pkgPath =
|
|
3240
|
-
const hasPackageJson =
|
|
3429
|
+
const pkgPath = join7(repoPath, "package.json");
|
|
3430
|
+
const hasPackageJson = existsSync7(pkgPath);
|
|
3241
3431
|
let packageManagerField;
|
|
3242
3432
|
if (hasPackageJson) {
|
|
3243
3433
|
try {
|
|
3244
|
-
const pkg = JSON.parse(
|
|
3434
|
+
const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
|
|
3245
3435
|
if (typeof pkg.packageManager === "string")
|
|
3246
3436
|
packageManagerField = pkg.packageManager;
|
|
3247
3437
|
} catch {}
|
|
@@ -3250,14 +3440,14 @@ function readRepoToolchain(repoPath) {
|
|
|
3250
3440
|
hasPackageJson,
|
|
3251
3441
|
...packageManagerField !== undefined ? { packageManagerField } : {},
|
|
3252
3442
|
lockfiles: {
|
|
3253
|
-
bun:
|
|
3254
|
-
pnpm:
|
|
3255
|
-
yarn:
|
|
3256
|
-
npm:
|
|
3443
|
+
bun: existsSync7(join7(repoPath, "bun.lock")) || existsSync7(join7(repoPath, "bun.lockb")),
|
|
3444
|
+
pnpm: existsSync7(join7(repoPath, "pnpm-lock.yaml")),
|
|
3445
|
+
yarn: existsSync7(join7(repoPath, "yarn.lock")),
|
|
3446
|
+
npm: existsSync7(join7(repoPath, "package-lock.json"))
|
|
3257
3447
|
}
|
|
3258
3448
|
};
|
|
3259
3449
|
}
|
|
3260
|
-
function createStreamThrottle(
|
|
3450
|
+
function createStreamThrottle(emit3, opts = {}) {
|
|
3261
3451
|
const intervalMs = opts.intervalMs ?? 2000;
|
|
3262
3452
|
const maxChars = opts.maxChars ?? 500;
|
|
3263
3453
|
const now = opts.now ?? Date.now;
|
|
@@ -3267,7 +3457,7 @@ function createStreamThrottle(emit2, opts = {}) {
|
|
|
3267
3457
|
const flushText = () => {
|
|
3268
3458
|
if (buffer.length === 0)
|
|
3269
3459
|
return;
|
|
3270
|
-
|
|
3460
|
+
emit3({ type: "burn.text", message: buffer, data: { iteration: lastIteration } });
|
|
3271
3461
|
buffer = "";
|
|
3272
3462
|
lastFlush = now();
|
|
3273
3463
|
};
|
|
@@ -3282,7 +3472,7 @@ function createStreamThrottle(emit2, opts = {}) {
|
|
|
3282
3472
|
flushText();
|
|
3283
3473
|
const rawArgs = event.formattedArgs ?? "";
|
|
3284
3474
|
const args = rawArgs.length > 200 ? `${rawArgs.slice(0, 200)}\u2026` : rawArgs;
|
|
3285
|
-
|
|
3475
|
+
emit3({
|
|
3286
3476
|
type: "burn.tool",
|
|
3287
3477
|
message: args.length > 0 ? `${event.name} ${args}` : event.name,
|
|
3288
3478
|
data: { name: event.name, args: rawArgs, iteration: event.iteration }
|
|
@@ -3428,38 +3618,38 @@ function createSerialQueue() {
|
|
|
3428
3618
|
};
|
|
3429
3619
|
}
|
|
3430
3620
|
async function landWithResolve(branch, deps) {
|
|
3431
|
-
let
|
|
3621
|
+
let current = branch;
|
|
3432
3622
|
for (let attempt = 1;; attempt++) {
|
|
3433
|
-
const merge = await deps.merge(
|
|
3623
|
+
const merge = await deps.merge(current);
|
|
3434
3624
|
if (merge.ok)
|
|
3435
|
-
return { status: "landed", branch:
|
|
3625
|
+
return { status: "landed", branch: current };
|
|
3436
3626
|
const detail = merge.error ?? "merge failed";
|
|
3437
3627
|
if (!merge.conflict)
|
|
3438
|
-
return { status: "failed", branch:
|
|
3628
|
+
return { status: "failed", branch: current, error: detail };
|
|
3439
3629
|
const files = merge.files ?? [];
|
|
3440
3630
|
if (attempt > deps.maxResolveAttempts) {
|
|
3441
|
-
return { status: "conflict", branch:
|
|
3631
|
+
return { status: "conflict", branch: current, files, error: detail };
|
|
3442
3632
|
}
|
|
3443
3633
|
const where = files.length > 0 ? `${files.length} file(s)` : "the merge";
|
|
3444
3634
|
deps.emit({
|
|
3445
3635
|
type: "merge.conflict.resolving",
|
|
3446
3636
|
message: `${deps.label}: conflicts with ${deps.featureBranch} on ${where} \u2014 resolving with an agent (pass ${attempt}/${deps.maxResolveAttempts})`,
|
|
3447
|
-
data: { branch:
|
|
3637
|
+
data: { branch: current, files, attempt, maxAttempts: deps.maxResolveAttempts }
|
|
3448
3638
|
});
|
|
3449
3639
|
const resolved = await deps.resolve({
|
|
3450
|
-
branch:
|
|
3640
|
+
branch: current,
|
|
3451
3641
|
files,
|
|
3452
3642
|
attempt,
|
|
3453
3643
|
maxAttempts: deps.maxResolveAttempts
|
|
3454
3644
|
});
|
|
3455
|
-
|
|
3645
|
+
current = resolved.branch;
|
|
3456
3646
|
if (!resolved.ok) {
|
|
3457
|
-
return { status: "conflict", branch:
|
|
3647
|
+
return { status: "conflict", branch: current, files, error: resolved.error ?? detail };
|
|
3458
3648
|
}
|
|
3459
3649
|
deps.emit({
|
|
3460
3650
|
type: "merge.conflict.resolved",
|
|
3461
|
-
message: `${deps.label}: conflict resolved on ${
|
|
3462
|
-
data: { branch:
|
|
3651
|
+
message: `${deps.label}: conflict resolved on ${current} \u2014 landing`,
|
|
3652
|
+
data: { branch: current, files, attempt }
|
|
3463
3653
|
});
|
|
3464
3654
|
}
|
|
3465
3655
|
}
|
|
@@ -3617,26 +3807,26 @@ function resolverTemplatePath() {
|
|
|
3617
3807
|
}
|
|
3618
3808
|
function burnerAssetPath(file) {
|
|
3619
3809
|
const here = dirname3(fileURLToPath2(import.meta.url));
|
|
3620
|
-
return
|
|
3810
|
+
return join7(resolveSkillsRoot(here), "burner", file);
|
|
3621
3811
|
}
|
|
3622
3812
|
function readDocsDigestFromDisk(projectId, slug) {
|
|
3623
3813
|
const worktree = worktreeDir(projectId, slug);
|
|
3624
|
-
if (!
|
|
3814
|
+
if (!existsSync7(worktree))
|
|
3625
3815
|
return "_No talk worktree on disk \u2014 docs digest skipped._";
|
|
3626
|
-
const docsDir =
|
|
3627
|
-
if (!
|
|
3816
|
+
const docsDir = join7(worktree, ...featureDocsRel(slug).split("/"));
|
|
3817
|
+
if (!existsSync7(docsDir))
|
|
3628
3818
|
return "_No docs/features dir in the talk worktree \u2014 docs digest skipped._";
|
|
3629
|
-
const files =
|
|
3819
|
+
const files = readdirSync2(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content: readFileSync3(join7(docsDir, e.name), "utf8") }));
|
|
3630
3820
|
return buildDocsDigest(files);
|
|
3631
3821
|
}
|
|
3632
3822
|
function readBlockedFile(dirs) {
|
|
3633
3823
|
for (const dir of dirs) {
|
|
3634
3824
|
if (!dir)
|
|
3635
3825
|
continue;
|
|
3636
|
-
const p =
|
|
3637
|
-
if (
|
|
3826
|
+
const p = join7(dir, "BLOCKED.md");
|
|
3827
|
+
if (existsSync7(p)) {
|
|
3638
3828
|
try {
|
|
3639
|
-
return
|
|
3829
|
+
return readFileSync3(p, "utf8");
|
|
3640
3830
|
} catch {}
|
|
3641
3831
|
}
|
|
3642
3832
|
}
|
|
@@ -3689,8 +3879,9 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3689
3879
|
const featureBrief = buildFeatureBrief(feature);
|
|
3690
3880
|
const docsDigest = readDocsDigestFromDisk(project.id, feature.slug);
|
|
3691
3881
|
const workspaceNotes = buildWorkspaceNotes(workspaceMode);
|
|
3692
|
-
const
|
|
3693
|
-
const
|
|
3882
|
+
const prepared = resolvePreparedSettings(config2, project);
|
|
3883
|
+
const verifyNotes = buildVerifyNotes(prepared);
|
|
3884
|
+
const basePrompt = renderTicketPrompt(readFileSync3(burnerTemplatePath(), "utf8"), {
|
|
3694
3885
|
TICKET_JSON: ticketJson,
|
|
3695
3886
|
FEATURE_BRIEF: featureBrief,
|
|
3696
3887
|
DOCS_DIGEST: docsDigest,
|
|
@@ -3700,19 +3891,19 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3700
3891
|
});
|
|
3701
3892
|
const toolchain = readRepoToolchain(project.repoPath);
|
|
3702
3893
|
const pm = detectPackageManager(toolchain);
|
|
3703
|
-
const setupCommand = resolveSetupCommand(toolchain,
|
|
3894
|
+
const setupCommand = resolveSetupCommand(toolchain, prepared.setupCommand);
|
|
3704
3895
|
const mounts = [];
|
|
3705
3896
|
if (config2.sandbox !== "noSandbox" && pm) {
|
|
3706
3897
|
const mount = cacheMountFor(pm, burnCacheDir(pm));
|
|
3707
3898
|
if (mount) {
|
|
3708
|
-
|
|
3899
|
+
mkdirSync3(mount.hostPath, { recursive: true });
|
|
3709
3900
|
mounts.push(mount);
|
|
3710
3901
|
}
|
|
3711
3902
|
}
|
|
3712
3903
|
const guardInstall = config2.burnGuard && config2.sandbox !== "noSandbox" ? buildGuardInstallCommand() : undefined;
|
|
3713
3904
|
const withGuard = (setup) => guardInstall === undefined ? setup : setup ? `${guardInstall} && ${setup}` : guardInstall;
|
|
3714
|
-
|
|
3715
|
-
const logFilePath =
|
|
3905
|
+
mkdirSync3(logsDir(), { recursive: true });
|
|
3906
|
+
const logFilePath = join7(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
|
|
3716
3907
|
const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
|
|
3717
3908
|
beginTranscript(ticket.id);
|
|
3718
3909
|
const timer = createToolTimer();
|
|
@@ -3758,7 +3949,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
|
|
|
3758
3949
|
const runResolver = async (input) => {
|
|
3759
3950
|
const resolveBranch = ticketBranchName(feature.slug, ticket.seq, newId("r").slice(2, 10));
|
|
3760
3951
|
const otherSide = await commitSummaries(project.repoPath, input.branch, feature.branch);
|
|
3761
|
-
const prompt =
|
|
3952
|
+
const prompt = renderTemplate2(readFileSync3(resolverTemplatePath(), "utf8"), {
|
|
3762
3953
|
TICKET_JSON: ticketJson,
|
|
3763
3954
|
FEATURE_BRIEF: featureBrief,
|
|
3764
3955
|
DOCS_DIGEST: docsDigest,
|
|
@@ -4061,9 +4252,9 @@ function resolveBurnDeps(ctx) {
|
|
|
4061
4252
|
}
|
|
4062
4253
|
function readTokenFromEnvFile(path) {
|
|
4063
4254
|
let fromFile;
|
|
4064
|
-
if (
|
|
4255
|
+
if (existsSync7(path)) {
|
|
4065
4256
|
try {
|
|
4066
|
-
fromFile = parseEnvFile(
|
|
4257
|
+
fromFile = parseEnvFile(readFileSync3(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
|
|
4067
4258
|
} catch {
|
|
4068
4259
|
fromFile = undefined;
|
|
4069
4260
|
}
|
|
@@ -4134,19 +4325,933 @@ var init_ticket_burner = __esm(() => {
|
|
|
4134
4325
|
/\bapi error\b/i,
|
|
4135
4326
|
/session capture failed/i
|
|
4136
4327
|
];
|
|
4137
|
-
activeTicketAborts = new Map;
|
|
4138
|
-
SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
|
|
4139
|
-
ticketBurner = {
|
|
4140
|
-
id: "ticket-burner",
|
|
4141
|
-
run: (ctx) => burnRun(ctx, resolveBurnDeps(ctx))
|
|
4142
|
-
};
|
|
4328
|
+
activeTicketAborts = new Map;
|
|
4329
|
+
SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
|
|
4330
|
+
ticketBurner = {
|
|
4331
|
+
id: "ticket-burner",
|
|
4332
|
+
run: (ctx) => burnRun(ctx, resolveBurnDeps(ctx))
|
|
4333
|
+
};
|
|
4334
|
+
});
|
|
4335
|
+
|
|
4336
|
+
// src/workflows/project-prep.ts
|
|
4337
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync4 } from "fs";
|
|
4338
|
+
import { dirname as dirname4, join as join8 } from "path";
|
|
4339
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
4340
|
+
import { run as run2 } from "@ai-hero/sandcastle";
|
|
4341
|
+
import * as z3 from "zod";
|
|
4342
|
+
function parsePrepFindings(raw) {
|
|
4343
|
+
let json;
|
|
4344
|
+
try {
|
|
4345
|
+
json = JSON.parse(raw);
|
|
4346
|
+
} catch (e) {
|
|
4347
|
+
throw new Error(`findings file is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
4348
|
+
}
|
|
4349
|
+
const outer = RawFindings.safeParse(json);
|
|
4350
|
+
if (!outer.success)
|
|
4351
|
+
throw new Error("findings file is not a JSON object");
|
|
4352
|
+
const warnings = [];
|
|
4353
|
+
const values = {};
|
|
4354
|
+
const known = new Set(PREPARED_KEYS);
|
|
4355
|
+
for (const [key, rawValue] of Object.entries(outer.data)) {
|
|
4356
|
+
if (key === "notes")
|
|
4357
|
+
continue;
|
|
4358
|
+
if (!known.has(key)) {
|
|
4359
|
+
warnings.push(`ignored unknown key "${key}"`);
|
|
4360
|
+
continue;
|
|
4361
|
+
}
|
|
4362
|
+
const parsed = RawFinding.safeParse(rawValue);
|
|
4363
|
+
if (!parsed.success) {
|
|
4364
|
+
warnings.push(`ignored "${key}": not a string or {value, evidence} object`);
|
|
4365
|
+
continue;
|
|
4366
|
+
}
|
|
4367
|
+
const entry = parsed.data;
|
|
4368
|
+
const value = (typeof entry === "string" ? entry : entry.value ?? "").trim();
|
|
4369
|
+
if (value === "")
|
|
4370
|
+
continue;
|
|
4371
|
+
const evidence = typeof entry === "string" ? undefined : entry.evidence?.trim();
|
|
4372
|
+
values[key] = { value, ...evidence ? { evidence } : {} };
|
|
4373
|
+
}
|
|
4374
|
+
const notes = outer.data.notes?.trim();
|
|
4375
|
+
return { findings: { values, ...notes ? { notes } : {} }, warnings };
|
|
4376
|
+
}
|
|
4377
|
+
function renderPrepPrompt(template, values) {
|
|
4378
|
+
let out = template;
|
|
4379
|
+
for (const key of PLACEHOLDERS) {
|
|
4380
|
+
out = out.split(`{{${key}}}`).join(values[key]);
|
|
4381
|
+
}
|
|
4382
|
+
return out;
|
|
4383
|
+
}
|
|
4384
|
+
function buildRequestedKeysBlock(keys) {
|
|
4385
|
+
if (keys.length === 0) {
|
|
4386
|
+
return "_Everything is already established. Report an empty findings object and stop._";
|
|
4387
|
+
}
|
|
4388
|
+
const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
|
|
4389
|
+
return [...keys].sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0)).map((k) => `- ${KEY_BRIEF[k]}`).join(`
|
|
4390
|
+
`);
|
|
4391
|
+
}
|
|
4392
|
+
async function prepRun(ctx, deps) {
|
|
4393
|
+
if (ctx.keys.length === 0) {
|
|
4394
|
+
ctx.emitEvent({
|
|
4395
|
+
type: "prep.skipped",
|
|
4396
|
+
message: "nothing to prepare \u2014 every field is already set"
|
|
4397
|
+
});
|
|
4398
|
+
return { status: "succeeded", summary: "nothing to prepare" };
|
|
4399
|
+
}
|
|
4400
|
+
if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
|
|
4401
|
+
ctx.emitEvent({ type: AUTH_MISSING_EVENT2, message: AUTH_MISSING_MESSAGE2 });
|
|
4402
|
+
return { status: "failed", summary: "preparation aborted: auth token missing" };
|
|
4403
|
+
}
|
|
4404
|
+
ctx.emitEvent({
|
|
4405
|
+
type: "prep.started",
|
|
4406
|
+
message: `preparing ${ctx.project.name} \u2014 establishing ${ctx.keys.join(", ")}`,
|
|
4407
|
+
data: { keys: [...ctx.keys] }
|
|
4408
|
+
});
|
|
4409
|
+
const outcome = await deps.executePrepRun(ctx);
|
|
4410
|
+
if (outcome.status === "failed") {
|
|
4411
|
+
ctx.emitEvent({
|
|
4412
|
+
type: "prep.failed",
|
|
4413
|
+
message: `preparation failed: ${errorHeadline2(outcome.error)}`,
|
|
4414
|
+
data: { error: outcome.error }
|
|
4415
|
+
});
|
|
4416
|
+
return { status: "failed", summary: outcome.error };
|
|
4417
|
+
}
|
|
4418
|
+
for (const warning of outcome.warnings) {
|
|
4419
|
+
ctx.emitEvent({ type: "prep.warning", message: `preparation: ${warning}` });
|
|
4420
|
+
}
|
|
4421
|
+
const found = Object.keys(outcome.findings.values);
|
|
4422
|
+
const missed = ctx.keys.filter((k) => !found.includes(k));
|
|
4423
|
+
const summary = found.length === 0 ? "preparation established nothing" : `established ${found.join(", ")}${missed.length > 0 ? ` (could not establish ${missed.join(", ")})` : ""}`;
|
|
4424
|
+
return { status: "succeeded", summary, findings: outcome.findings };
|
|
4425
|
+
}
|
|
4426
|
+
function errorHeadline2(s) {
|
|
4427
|
+
const lines = s.split(`
|
|
4428
|
+
`).map((l) => l.trim()).filter(Boolean);
|
|
4429
|
+
const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
|
|
4430
|
+
return causes.at(-1) ?? lines[0] ?? "";
|
|
4431
|
+
}
|
|
4432
|
+
function createPrepStreamThrottle(emit3, opts = {}) {
|
|
4433
|
+
return createStreamThrottle((e) => emit3({ ...e, type: e.type.replace(/^burn\./, "prep.") }), opts);
|
|
4434
|
+
}
|
|
4435
|
+
function nonFatalSetup(setupCommand) {
|
|
4436
|
+
return `( ${setupCommand} || true )`;
|
|
4437
|
+
}
|
|
4438
|
+
function prepTemplatePath() {
|
|
4439
|
+
const here = dirname4(fileURLToPath3(import.meta.url));
|
|
4440
|
+
return join8(resolveSkillsRoot(here), "burner", "prepare-project.md");
|
|
4441
|
+
}
|
|
4442
|
+
async function realExecutePrepRun(ctx, config2, token, model) {
|
|
4443
|
+
const { project } = ctx;
|
|
4444
|
+
const branch = prepBranchName(newId("b").slice(2, 10));
|
|
4445
|
+
const toolchain = readRepoToolchain(project.repoPath);
|
|
4446
|
+
const pm = detectPackageManager(toolchain);
|
|
4447
|
+
const prepared = resolvePreparedSettings(config2, project);
|
|
4448
|
+
const setupCommand = resolveSetupCommand(toolchain, prepared.setupCommand);
|
|
4449
|
+
const workspaceMode = resolveBurnWorkspaceMode(config2);
|
|
4450
|
+
const mounts = [];
|
|
4451
|
+
if (config2.sandbox !== "noSandbox" && pm) {
|
|
4452
|
+
const mount = cacheMountFor(pm, burnCacheDir(pm));
|
|
4453
|
+
if (mount) {
|
|
4454
|
+
mkdirSync4(mount.hostPath, { recursive: true });
|
|
4455
|
+
mounts.push(mount);
|
|
4456
|
+
}
|
|
4457
|
+
}
|
|
4458
|
+
const template = readFileSync4(prepTemplatePath(), "utf8");
|
|
4459
|
+
const prompt = renderPrepPrompt(template, {
|
|
4460
|
+
REQUESTED_KEYS: buildRequestedKeysBlock(ctx.keys),
|
|
4461
|
+
SETUP_COMMAND: setupCommand ?? "(no install step detected)"
|
|
4462
|
+
});
|
|
4463
|
+
mkdirSync4(logsDir(), { recursive: true });
|
|
4464
|
+
const logFilePath = join8(logsDir(), `prep-${project.id}.log`);
|
|
4465
|
+
const throttle = createPrepStreamThrottle((e) => ctx.emitEvent(e));
|
|
4466
|
+
if (setupCommand) {
|
|
4467
|
+
ctx.emitEvent({
|
|
4468
|
+
type: "prep.setup",
|
|
4469
|
+
message: `attempting a dependency install before the agent starts (best-effort): ${setupCommand}`
|
|
4470
|
+
});
|
|
4471
|
+
}
|
|
4472
|
+
const prepSetupCommand = setupCommand ? nonFatalSetup(setupCommand) : undefined;
|
|
4473
|
+
const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(branch, prepSetupCommand, pm) : prepSetupCommand;
|
|
4474
|
+
const runOptions = {
|
|
4475
|
+
agent: buildBurnAgent(config2, token, model),
|
|
4476
|
+
sandbox: selectSandbox(config2, mounts),
|
|
4477
|
+
cwd: project.repoPath,
|
|
4478
|
+
prompt,
|
|
4479
|
+
branchStrategy: { type: "branch", branch, baseBranch: project.mainBranch },
|
|
4480
|
+
signal: ctx.signal,
|
|
4481
|
+
name: "prepare",
|
|
4482
|
+
maxIterations: 1,
|
|
4483
|
+
...hookCommand ? {
|
|
4484
|
+
hooks: {
|
|
4485
|
+
sandbox: {
|
|
4486
|
+
onSandboxReady: [{ command: hookCommand, timeoutMs: PREP_SETUP_TIMEOUT_MS }]
|
|
4487
|
+
}
|
|
4488
|
+
}
|
|
4489
|
+
} : {},
|
|
4490
|
+
logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
|
|
4491
|
+
};
|
|
4492
|
+
try {
|
|
4493
|
+
let result;
|
|
4494
|
+
try {
|
|
4495
|
+
result = await run2(runOptions);
|
|
4496
|
+
} catch (err) {
|
|
4497
|
+
throttle.flush();
|
|
4498
|
+
if (ctx.signal.aborted)
|
|
4499
|
+
throw err;
|
|
4500
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4501
|
+
const salvaged = await readFindings(project.repoPath, branch);
|
|
4502
|
+
if (!salvaged)
|
|
4503
|
+
return { status: "failed", error: msg };
|
|
4504
|
+
ctx.emitEvent({
|
|
4505
|
+
type: "prep.salvaged",
|
|
4506
|
+
message: `the agent finished but sandcastle errored on teardown (${errorHeadline2(msg)}) \u2014 reading the findings it committed anyway`
|
|
4507
|
+
});
|
|
4508
|
+
return salvaged;
|
|
4509
|
+
}
|
|
4510
|
+
throttle.flush();
|
|
4511
|
+
if (result.commits.length === 0) {
|
|
4512
|
+
return {
|
|
4513
|
+
status: "failed",
|
|
4514
|
+
error: `agent produced no commits \u2014 no ${PREP_FINDINGS_PATH} landed`
|
|
4515
|
+
};
|
|
4516
|
+
}
|
|
4517
|
+
const read = await readFindings(project.repoPath, branch);
|
|
4518
|
+
return read ?? {
|
|
4519
|
+
status: "failed",
|
|
4520
|
+
error: `agent committed, but ${PREP_FINDINGS_PATH} is missing from ${branch}`
|
|
4521
|
+
};
|
|
4522
|
+
} finally {
|
|
4523
|
+
await deletePrepBranch(project.repoPath, branch);
|
|
4524
|
+
}
|
|
4525
|
+
}
|
|
4526
|
+
async function readFindings(repoPath, branch) {
|
|
4527
|
+
const raw = await readFileAtRef(repoPath, branch, PREP_FINDINGS_PATH);
|
|
4528
|
+
if (raw === undefined)
|
|
4529
|
+
return;
|
|
4530
|
+
try {
|
|
4531
|
+
const { findings, warnings } = parsePrepFindings(raw);
|
|
4532
|
+
return { status: "done", findings, warnings };
|
|
4533
|
+
} catch (e) {
|
|
4534
|
+
return { status: "failed", error: e instanceof Error ? e.message : String(e) };
|
|
4535
|
+
}
|
|
4536
|
+
}
|
|
4537
|
+
function resolvePrepDeps(project) {
|
|
4538
|
+
const config2 = loadConfig();
|
|
4539
|
+
const token = readTokenFromEnvFile(envPath());
|
|
4540
|
+
const model = resolveModel("prepare", config2, project);
|
|
4541
|
+
return {
|
|
4542
|
+
config: config2,
|
|
4543
|
+
hasAuthToken: token !== undefined,
|
|
4544
|
+
executePrepRun: (c) => realExecutePrepRun(c, config2, token, model)
|
|
4545
|
+
};
|
|
4546
|
+
}
|
|
4547
|
+
async function prepHeadSha(project) {
|
|
4548
|
+
return existsSync8(project.repoPath) ? headSha(project.repoPath, project.mainBranch) : undefined;
|
|
4549
|
+
}
|
|
4550
|
+
var AUTH_MISSING_EVENT2 = "auth.missing", AUTH_MISSING_MESSAGE2 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PREP_FINDINGS_PATH = ".runcastle/prep.json", PREP_SETUP_TIMEOUT_MS, RawFinding, RawFindings, PLACEHOLDERS, KEY_BRIEF;
|
|
4551
|
+
var init_project_prep = __esm(() => {
|
|
4552
|
+
init_src();
|
|
4553
|
+
init_config_load();
|
|
4554
|
+
init_paths();
|
|
4555
|
+
init_skills_root();
|
|
4556
|
+
init_git();
|
|
4557
|
+
init_ticket_burner();
|
|
4558
|
+
PREP_SETUP_TIMEOUT_MS = 15 * 60000;
|
|
4559
|
+
RawFinding = z3.union([
|
|
4560
|
+
z3.string(),
|
|
4561
|
+
z3.object({
|
|
4562
|
+
value: z3.string().nullish(),
|
|
4563
|
+
evidence: z3.string().nullish()
|
|
4564
|
+
})
|
|
4565
|
+
]);
|
|
4566
|
+
RawFindings = z3.object({ notes: z3.string().nullish() }).catchall(z3.unknown());
|
|
4567
|
+
PLACEHOLDERS = ["REQUESTED_KEYS", "SETUP_COMMAND"];
|
|
4568
|
+
KEY_BRIEF = {
|
|
4569
|
+
setupCommand: "**`setupCommand`** \u2014 the command that takes a clean checkout to a state where the code builds and tests run: dependency install, plus any codegen or contract build that every task would otherwise discover it needed mid-flight (ORM client generation, protobuf, a `contracts:build` step). Run it and report what worked.",
|
|
4570
|
+
verifyCommands: "**`verifyCommands`** \u2014 the exact typecheck / test / lint commands for this repo, one per line. In a monorepo these must include the correct workspace filter names. Run each one and confirm it executes the thing it claims to.",
|
|
4571
|
+
knownFailures: '**`knownFailures`** \u2014 which tests are ALREADY failing on this checkout, before anything is changed. Run the full suite once and report the count plus the failing suite names. "0 known failures" is a real, useful answer.',
|
|
4572
|
+
devCommand: "**`devCommand`** \u2014 the command that starts this project's dev server on a developer's own machine. Read it from config; do not run it here.",
|
|
4573
|
+
driveSetupCommand: "**`driveSetupCommand`** \u2014 a single shell command that takes this repo from a fresh checkout to \"the dev server would actually work\": backing services up, schema applied, whatever this project needs. Chain steps with `&&`. Read it from the repo's own config (compose file, Makefile, package scripts, README); do not run it here. Omit it if the dev command alone is enough \u2014 an empty answer is better than a guessed one, because this runs on a developer's real machine.",
|
|
4574
|
+
driveStopCommand: "**`driveStopCommand`** \u2014 the matching teardown for `driveSetupCommand`, if this project has one (`docker compose down`, a stop script). Read it from config; do not run it here. Omit it if setup leaves nothing that needs stopping.",
|
|
4575
|
+
driveEnv: "**`driveEnv`** \u2014 `KEY=VALUE` lines (one per line) overlaid on the dev server's environment during a test drive, with `{{slug}}`, `{{branch}}` and `{{id}}` (an identifier-safe slug) rendered per drive. Its purpose is giving a branch its own database, e.g. `DATABASE_URL=postgres://localhost:5432/myapp_{{id}}` paired with a `driveSetupCommand` that creates it. Report it ONLY if you can read the real connection variable's name and shape out of the repo's own config or `.env.example`; a connection string you assembled from defaults points at the wrong database convincingly, which is worse than omitting the key. Omit it if the project has no database or you had to guess the host, port or credentials.",
|
|
4576
|
+
dbResetCommand: "**`dbResetCommand`** \u2014 the command that rebuilds the dev database from the migrations currently in the working tree. Read it from config; do not run it here. Omit it entirely if this repo has no database."
|
|
4577
|
+
};
|
|
4578
|
+
});
|
|
4579
|
+
|
|
4580
|
+
// src/services/findings.ts
|
|
4581
|
+
import { and as and5, eq as eq5 } from "drizzle-orm";
|
|
4582
|
+
function isPreparedKey(key) {
|
|
4583
|
+
return PREPARED_SET.has(key);
|
|
4584
|
+
}
|
|
4585
|
+
function findingRow(ctx, projectId, key) {
|
|
4586
|
+
return ctx.db.select({
|
|
4587
|
+
source: projectFindings.source,
|
|
4588
|
+
evidence: projectFindings.evidence,
|
|
4589
|
+
establishedAt: projectFindings.establishedAt,
|
|
4590
|
+
establishedSha: projectFindings.establishedSha
|
|
4591
|
+
}).from(projectFindings).where(and5(eq5(projectFindings.projectId, projectId), eq5(projectFindings.key, key))).get();
|
|
4592
|
+
}
|
|
4593
|
+
function isOverwritable(ctx, projectId, key) {
|
|
4594
|
+
return findingRow(ctx, projectId, key)?.source !== "human";
|
|
4595
|
+
}
|
|
4596
|
+
function recordFinding(ctx, projectId, input) {
|
|
4597
|
+
const { key, value, source, evidence, establishedSha } = input;
|
|
4598
|
+
ctx.db.transaction((tx) => {
|
|
4599
|
+
tx.update(projects).set({ [COLUMN_NAME[key]]: value }).where(eq5(projects.id, projectId)).run();
|
|
4600
|
+
if (value === null) {
|
|
4601
|
+
tx.delete(projectFindings).where(and5(eq5(projectFindings.projectId, projectId), eq5(projectFindings.key, key))).run();
|
|
4602
|
+
return;
|
|
4603
|
+
}
|
|
4604
|
+
tx.insert(projectFindings).values({
|
|
4605
|
+
projectId,
|
|
4606
|
+
key,
|
|
4607
|
+
source,
|
|
4608
|
+
evidence: evidence ?? null,
|
|
4609
|
+
establishedAt: Date.now(),
|
|
4610
|
+
establishedSha: establishedSha ?? null
|
|
4611
|
+
}).onConflictDoUpdate({
|
|
4612
|
+
target: [projectFindings.projectId, projectFindings.key],
|
|
4613
|
+
set: {
|
|
4614
|
+
source,
|
|
4615
|
+
evidence: evidence ?? null,
|
|
4616
|
+
establishedAt: Date.now(),
|
|
4617
|
+
establishedSha: establishedSha ?? null
|
|
4618
|
+
}
|
|
4619
|
+
}).run();
|
|
4620
|
+
});
|
|
4621
|
+
}
|
|
4622
|
+
function recordHuman(ctx, projectId, key, value) {
|
|
4623
|
+
if (!isPreparedKey(key))
|
|
4624
|
+
return;
|
|
4625
|
+
const existing = findingRow(ctx, projectId, key);
|
|
4626
|
+
if (value === null) {
|
|
4627
|
+
if (existing) {
|
|
4628
|
+
ctx.db.delete(projectFindings).where(and5(eq5(projectFindings.projectId, projectId), eq5(projectFindings.key, key))).run();
|
|
4629
|
+
}
|
|
4630
|
+
return;
|
|
4631
|
+
}
|
|
4632
|
+
ctx.db.insert(projectFindings).values({
|
|
4633
|
+
projectId,
|
|
4634
|
+
key,
|
|
4635
|
+
source: "human",
|
|
4636
|
+
evidence: null,
|
|
4637
|
+
establishedAt: Date.now(),
|
|
4638
|
+
establishedSha: null
|
|
4639
|
+
}).onConflictDoUpdate({
|
|
4640
|
+
target: [projectFindings.projectId, projectFindings.key],
|
|
4641
|
+
set: { source: "human", evidence: null, establishedAt: Date.now(), establishedSha: null }
|
|
4642
|
+
}).run();
|
|
4643
|
+
}
|
|
4644
|
+
async function listFindings(ctx, project) {
|
|
4645
|
+
const rows = ctx.db.select().from(projectFindings).where(eq5(projectFindings.projectId, project.id)).all();
|
|
4646
|
+
const distances = new Map;
|
|
4647
|
+
for (const row of rows) {
|
|
4648
|
+
const sha = row.establishedSha;
|
|
4649
|
+
if (!sha || distances.has(sha))
|
|
4650
|
+
continue;
|
|
4651
|
+
distances.set(sha, await commitsSince(project.repoPath, sha, project.mainBranch));
|
|
4652
|
+
}
|
|
4653
|
+
const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
|
|
4654
|
+
return rows.filter((row) => isPreparedKey(row.key)).sort((a, b) => (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0)).map((row) => {
|
|
4655
|
+
const staleCommits = row.establishedSha ? distances.get(row.establishedSha) : undefined;
|
|
4656
|
+
return {
|
|
4657
|
+
key: row.key,
|
|
4658
|
+
source: row.source,
|
|
4659
|
+
...row.evidence ? { evidence: row.evidence } : {},
|
|
4660
|
+
establishedAt: row.establishedAt,
|
|
4661
|
+
...row.establishedSha ? { establishedSha: row.establishedSha } : {},
|
|
4662
|
+
...staleCommits !== undefined ? { staleCommits } : {}
|
|
4663
|
+
};
|
|
4664
|
+
});
|
|
4665
|
+
}
|
|
4666
|
+
function unsetPreparedKeys(project) {
|
|
4667
|
+
return PREPARED_KEYS.filter((key) => {
|
|
4668
|
+
const value = project[key];
|
|
4669
|
+
return value === undefined || value.trim() === "";
|
|
4670
|
+
});
|
|
4671
|
+
}
|
|
4672
|
+
var VALUE_COLUMN, COLUMN_NAME, PREPARED_SET;
|
|
4673
|
+
var init_findings = __esm(() => {
|
|
4674
|
+
init_src();
|
|
4675
|
+
init_schema();
|
|
4676
|
+
init_git();
|
|
4677
|
+
VALUE_COLUMN = {
|
|
4678
|
+
setupCommand: projects.setupCommand,
|
|
4679
|
+
verifyCommands: projects.verifyCommands,
|
|
4680
|
+
knownFailures: projects.knownFailures,
|
|
4681
|
+
devCommand: projects.devCommand,
|
|
4682
|
+
driveSetupCommand: projects.driveSetupCommand,
|
|
4683
|
+
driveStopCommand: projects.driveStopCommand,
|
|
4684
|
+
driveEnv: projects.driveEnv,
|
|
4685
|
+
dbResetCommand: projects.dbResetCommand
|
|
4686
|
+
};
|
|
4687
|
+
COLUMN_NAME = {
|
|
4688
|
+
setupCommand: "setupCommand",
|
|
4689
|
+
verifyCommands: "verifyCommands",
|
|
4690
|
+
knownFailures: "knownFailures",
|
|
4691
|
+
devCommand: "devCommand",
|
|
4692
|
+
driveSetupCommand: "driveSetupCommand",
|
|
4693
|
+
driveStopCommand: "driveStopCommand",
|
|
4694
|
+
driveEnv: "driveEnv",
|
|
4695
|
+
dbResetCommand: "dbResetCommand"
|
|
4696
|
+
};
|
|
4697
|
+
PREPARED_SET = new Set(PREPARED_KEYS);
|
|
4698
|
+
});
|
|
4699
|
+
|
|
4700
|
+
// src/services/prep.ts
|
|
4701
|
+
import { and as and6, desc as desc3, eq as eq6 } from "drizzle-orm";
|
|
4702
|
+
function isPreparing(projectId) {
|
|
4703
|
+
return controllers.has(projectId);
|
|
4704
|
+
}
|
|
4705
|
+
function keysToPrepare(ctx, project, opts = {}) {
|
|
4706
|
+
const unset = new Set(unsetPreparedKeys(project));
|
|
4707
|
+
const candidates = opts.keys ?? (opts.refresh ? PREPARED_KEYS : [...unset]);
|
|
4708
|
+
return candidates.filter((key) => (opts.refresh || unset.has(key)) && isOverwritable(ctx, project.id, key));
|
|
4709
|
+
}
|
|
4710
|
+
function shouldAutoPrepare(ctx, project) {
|
|
4711
|
+
if (!ctx.config.autoPrepare)
|
|
4712
|
+
return false;
|
|
4713
|
+
if (latestPrep(ctx, project.id))
|
|
4714
|
+
return false;
|
|
4715
|
+
return keysToPrepare(ctx, project).length > 0;
|
|
4716
|
+
}
|
|
4717
|
+
async function startPrep(ctx, projectId, opts = {}) {
|
|
4718
|
+
const project = requireProjectById(ctx, projectId);
|
|
4719
|
+
if (controllers.has(projectId)) {
|
|
4720
|
+
throw new InvalidInputError(`${project.name} is already being prepared`);
|
|
4721
|
+
}
|
|
4722
|
+
const keys = keysToPrepare(ctx, project, opts);
|
|
4723
|
+
const prepId = newId("prep");
|
|
4724
|
+
const headSha2 = await prepHeadSha(project);
|
|
4725
|
+
ctx.db.insert(projectPreps).values({
|
|
4726
|
+
id: prepId,
|
|
4727
|
+
projectId,
|
|
4728
|
+
status: "running",
|
|
4729
|
+
startedAt: Date.now(),
|
|
4730
|
+
endedAt: null,
|
|
4731
|
+
summary: null,
|
|
4732
|
+
headSha: headSha2 ?? null
|
|
4733
|
+
}).run();
|
|
4734
|
+
emitProject(ctx, projectId, {
|
|
4735
|
+
type: "prep.run.started",
|
|
4736
|
+
message: keys.length === 0 ? `preparing ${project.name} \u2014 nothing left to establish` : `preparing ${project.name} \u2014 ${keys.length} field(s) to establish`,
|
|
4737
|
+
data: { prepId, keys }
|
|
4738
|
+
});
|
|
4739
|
+
const controller = new AbortController;
|
|
4740
|
+
controllers.set(projectId, { runId: prepId, controller });
|
|
4741
|
+
const prepCtx = {
|
|
4742
|
+
project,
|
|
4743
|
+
keys,
|
|
4744
|
+
emitEvent: (e) => {
|
|
4745
|
+
emitProject(ctx, projectId, { type: e.type, message: e.message, data: e.data });
|
|
4746
|
+
},
|
|
4747
|
+
signal: controller.signal
|
|
4748
|
+
};
|
|
4749
|
+
const deps = opts.deps ?? resolvePrepDeps(project);
|
|
4750
|
+
const done = finalize(ctx, project, prepId, prepCtx, deps, controller, headSha2);
|
|
4751
|
+
return { prepId, keys, done };
|
|
4752
|
+
}
|
|
4753
|
+
function cancelPrep(projectId) {
|
|
4754
|
+
controllers.get(projectId)?.controller.abort();
|
|
4755
|
+
}
|
|
4756
|
+
async function finalize(ctx, project, prepId, prepCtx, deps, controller, headSha2) {
|
|
4757
|
+
let status = "failed";
|
|
4758
|
+
let summary = "preparation failed";
|
|
4759
|
+
let findings;
|
|
4760
|
+
try {
|
|
4761
|
+
const result = await prepRun(prepCtx, deps);
|
|
4762
|
+
status = result.status === "succeeded" ? "succeeded" : "failed";
|
|
4763
|
+
summary = result.summary;
|
|
4764
|
+
findings = result.findings;
|
|
4765
|
+
} catch (e) {
|
|
4766
|
+
if (controller.signal.aborted) {
|
|
4767
|
+
status = "cancelled";
|
|
4768
|
+
summary = "preparation cancelled";
|
|
4769
|
+
} else {
|
|
4770
|
+
status = "failed";
|
|
4771
|
+
summary = e instanceof Error ? e.message : "preparation failed";
|
|
4772
|
+
}
|
|
4773
|
+
} finally {
|
|
4774
|
+
controllers.delete(project.id);
|
|
4775
|
+
}
|
|
4776
|
+
const applied = findings ? applyFindings(ctx, project, findings, headSha2) : [];
|
|
4777
|
+
ctx.db.update(projectPreps).set({ status, endedAt: Date.now(), summary }).where(eq6(projectPreps.id, prepId)).run();
|
|
4778
|
+
emitProject(ctx, project.id, {
|
|
4779
|
+
type: "prep.run.finished",
|
|
4780
|
+
message: `preparation ${status}: ${summary}`,
|
|
4781
|
+
data: { prepId, status, summary, applied }
|
|
4782
|
+
});
|
|
4783
|
+
}
|
|
4784
|
+
function applyFindings(ctx, project, findings, headSha2) {
|
|
4785
|
+
const applied = [];
|
|
4786
|
+
for (const [key, finding] of Object.entries(findings.values)) {
|
|
4787
|
+
if (!isOverwritable(ctx, project.id, key)) {
|
|
4788
|
+
emitProject(ctx, project.id, {
|
|
4789
|
+
type: "prep.skipped_field",
|
|
4790
|
+
message: `kept your ${key} \u2014 preparation proposed "${truncate(finding.value)}" but you set this by hand`,
|
|
4791
|
+
data: { key, proposed: finding.value }
|
|
4792
|
+
});
|
|
4793
|
+
continue;
|
|
4794
|
+
}
|
|
4795
|
+
recordFinding(ctx, project.id, {
|
|
4796
|
+
key,
|
|
4797
|
+
value: finding.value,
|
|
4798
|
+
source: "prep",
|
|
4799
|
+
...finding.evidence ? { evidence: finding.evidence } : {},
|
|
4800
|
+
...headSha2 ? { establishedSha: headSha2 } : {}
|
|
4801
|
+
});
|
|
4802
|
+
applied.push(key);
|
|
4803
|
+
emitProject(ctx, project.id, {
|
|
4804
|
+
type: "prep.established",
|
|
4805
|
+
message: `${key}: ${truncate(finding.value)}`,
|
|
4806
|
+
data: { key, value: finding.value, evidence: finding.evidence }
|
|
4807
|
+
});
|
|
4808
|
+
}
|
|
4809
|
+
if (findings.notes) {
|
|
4810
|
+
emitProject(ctx, project.id, {
|
|
4811
|
+
type: "prep.notes",
|
|
4812
|
+
message: `preparation notes: ${findings.notes}`,
|
|
4813
|
+
data: { notes: findings.notes }
|
|
4814
|
+
});
|
|
4815
|
+
}
|
|
4816
|
+
return applied;
|
|
4817
|
+
}
|
|
4818
|
+
function truncate(value, max = 120) {
|
|
4819
|
+
const oneLine = value.replace(/\s*\n\s*/g, " \xB7 ").trim();
|
|
4820
|
+
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}\u2026` : oneLine;
|
|
4821
|
+
}
|
|
4822
|
+
function latestPrep(ctx, projectId) {
|
|
4823
|
+
const row = ctx.db.select().from(projectPreps).where(eq6(projectPreps.projectId, projectId)).orderBy(desc3(projectPreps.startedAt)).get();
|
|
4824
|
+
if (!row)
|
|
4825
|
+
return null;
|
|
4826
|
+
return {
|
|
4827
|
+
id: row.id,
|
|
4828
|
+
projectId: row.projectId,
|
|
4829
|
+
status: row.status,
|
|
4830
|
+
startedAt: row.startedAt,
|
|
4831
|
+
...row.endedAt !== null ? { endedAt: row.endedAt } : {},
|
|
4832
|
+
...row.summary !== null ? { summary: row.summary } : {},
|
|
4833
|
+
...row.headSha !== null ? { headSha: row.headSha } : {}
|
|
4834
|
+
};
|
|
4835
|
+
}
|
|
4836
|
+
function latestPrepNotes(ctx, projectId) {
|
|
4837
|
+
const row = ctx.db.select({ data: events.data }).from(events).where(and6(eq6(events.projectId, projectId), eq6(events.type, "prep.notes"))).orderBy(desc3(events.id)).get();
|
|
4838
|
+
const notes = row?.data?.notes;
|
|
4839
|
+
return typeof notes === "string" && notes.trim() !== "" ? notes : undefined;
|
|
4840
|
+
}
|
|
4841
|
+
function reconcilePreps(ctx) {
|
|
4842
|
+
const stale = ctx.db.select({ id: projectPreps.id, projectId: projectPreps.projectId }).from(projectPreps).where(eq6(projectPreps.status, "running")).all().filter((row) => !controllers.has(row.projectId));
|
|
4843
|
+
for (const row of stale) {
|
|
4844
|
+
ctx.db.update(projectPreps).set({ status: "failed", endedAt: Date.now(), summary: "server stopped while preparing" }).where(eq6(projectPreps.id, row.id)).run();
|
|
4845
|
+
}
|
|
4846
|
+
return stale.length;
|
|
4847
|
+
}
|
|
4848
|
+
var controllers;
|
|
4849
|
+
var init_prep = __esm(() => {
|
|
4850
|
+
init_src();
|
|
4851
|
+
init_schema();
|
|
4852
|
+
init_errors();
|
|
4853
|
+
init_project_prep();
|
|
4854
|
+
init_events();
|
|
4855
|
+
init_findings();
|
|
4856
|
+
init_repo();
|
|
4857
|
+
controllers = new Map;
|
|
4858
|
+
});
|
|
4859
|
+
|
|
4860
|
+
// src/launcher/runtime.ts
|
|
4861
|
+
function setRuntimeCtx(ctx) {
|
|
4862
|
+
current = ctx;
|
|
4863
|
+
}
|
|
4864
|
+
async function getRuntimeCtx() {
|
|
4865
|
+
if (current)
|
|
4866
|
+
return current;
|
|
4867
|
+
const [{ createDb: createDb2 }, { dbPath: dbPath2 }, { loadConfig: loadConfig2 }] = await Promise.all([
|
|
4868
|
+
Promise.resolve().then(() => (init_client(), exports_client)),
|
|
4869
|
+
Promise.resolve().then(() => (init_paths(), exports_paths)),
|
|
4870
|
+
Promise.resolve().then(() => (init_config_load(), exports_config_load))
|
|
4871
|
+
]);
|
|
4872
|
+
const db = createDb2(dbPath2());
|
|
4873
|
+
current = { db, config: loadConfig2() };
|
|
4874
|
+
return current;
|
|
4875
|
+
}
|
|
4876
|
+
var current = null;
|
|
4877
|
+
|
|
4878
|
+
// src/services/feature-docs.ts
|
|
4879
|
+
import { existsSync as existsSync9 } from "fs";
|
|
4880
|
+
import { join as join9 } from "path";
|
|
4881
|
+
function featureDocsDir(project, feature) {
|
|
4882
|
+
const relSegments = featureDocsRel(feature.slug).split("/");
|
|
4883
|
+
const worktree = worktreeDir(project.id, feature.slug);
|
|
4884
|
+
const base = existsSync9(worktree) ? worktree : project.repoPath;
|
|
4885
|
+
return join9(base, ...relSegments);
|
|
4886
|
+
}
|
|
4887
|
+
function featureDocPath(project, feature, fileName) {
|
|
4888
|
+
return join9(featureDocsDir(project, feature), fileName);
|
|
4889
|
+
}
|
|
4890
|
+
var init_feature_docs = __esm(() => {
|
|
4891
|
+
init_paths();
|
|
4892
|
+
});
|
|
4893
|
+
|
|
4894
|
+
// src/services/tickets.ts
|
|
4895
|
+
import { and as and7, asc as asc3, eq as eq7 } from "drizzle-orm";
|
|
4896
|
+
function rowToTicket(row) {
|
|
4897
|
+
return {
|
|
4898
|
+
id: row.id,
|
|
4899
|
+
featureId: row.featureId,
|
|
4900
|
+
seq: row.seq,
|
|
4901
|
+
title: row.title,
|
|
4902
|
+
goal: row.goal,
|
|
4903
|
+
context: row.context,
|
|
4904
|
+
acceptanceCriteria: row.acceptanceCriteria,
|
|
4905
|
+
seams: row.seams,
|
|
4906
|
+
blockedBy: row.blockedBy,
|
|
4907
|
+
status: row.status,
|
|
4908
|
+
commits: row.commits,
|
|
4909
|
+
error: row.error ?? undefined,
|
|
4910
|
+
attemptBranch: row.attemptBranch ?? undefined,
|
|
4911
|
+
conflictFiles: row.conflictFiles ?? undefined
|
|
4912
|
+
};
|
|
4913
|
+
}
|
|
4914
|
+
function resolveBlocking(inputs, startSeq) {
|
|
4915
|
+
try {
|
|
4916
|
+
return resolveBatchBlocking(inputs, { startSeq });
|
|
4917
|
+
} catch (e) {
|
|
4918
|
+
if (e instanceof BlockingEdgeError)
|
|
4919
|
+
throw new InvalidInputError(e.message);
|
|
4920
|
+
throw e;
|
|
4921
|
+
}
|
|
4922
|
+
}
|
|
4923
|
+
function listByFeature2(ctx, featureId) {
|
|
4924
|
+
return ctx.db.select().from(tickets).where(eq7(tickets.featureId, featureId)).orderBy(asc3(tickets.seq)).all().map(rowToTicket);
|
|
4925
|
+
}
|
|
4926
|
+
function storeTickets(ctx, featureId, inputs) {
|
|
4927
|
+
if (inputs.length === 0)
|
|
4928
|
+
return [];
|
|
4929
|
+
const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq7(tickets.featureId, featureId)).all();
|
|
4930
|
+
const startSeq = existing.reduce((max, r) => Math.max(max, r.seq), 0) + 1;
|
|
4931
|
+
const resolved = resolveBlocking(inputs, startSeq);
|
|
4932
|
+
const rows = inputs.map((t, i) => ({
|
|
4933
|
+
id: newId("tkt"),
|
|
4934
|
+
featureId,
|
|
4935
|
+
seq: resolved[i].seq,
|
|
4936
|
+
title: t.title,
|
|
4937
|
+
goal: t.goal,
|
|
4938
|
+
context: t.context,
|
|
4939
|
+
acceptanceCriteria: t.acceptanceCriteria,
|
|
4940
|
+
seams: t.seams,
|
|
4941
|
+
blockedBy: resolved[i].blockedBy,
|
|
4942
|
+
status: "pending",
|
|
4943
|
+
commits: [],
|
|
4944
|
+
error: null,
|
|
4945
|
+
attemptBranch: null,
|
|
4946
|
+
conflictFiles: null
|
|
4947
|
+
}));
|
|
4948
|
+
ctx.db.insert(tickets).values(rows).run();
|
|
4949
|
+
emit(ctx, featureId, {
|
|
4950
|
+
type: "tickets.stored",
|
|
4951
|
+
message: `${rows.length} ticket(s) stored`,
|
|
4952
|
+
data: { count: rows.length, seqs: rows.map((r) => r.seq) }
|
|
4953
|
+
});
|
|
4954
|
+
return rows.map(rowToTicket);
|
|
4955
|
+
}
|
|
4956
|
+
function getTicket(ctx, id) {
|
|
4957
|
+
const row = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
|
|
4958
|
+
if (!row)
|
|
4959
|
+
throw new NotFoundError(`ticket ${id} not found`);
|
|
4960
|
+
return rowToTicket(row);
|
|
4961
|
+
}
|
|
4962
|
+
function assertMutable(t, verb) {
|
|
4963
|
+
if (!MUTABLE_STATUSES.has(t.status)) {
|
|
4964
|
+
throw new InvalidInputError(`cannot ${verb} ticket ${t.seq} \u2014 it is ${t.status}; only pending or failed tickets can be ${verb}${verb.endsWith("l") ? "led" : "ed"}`);
|
|
4965
|
+
}
|
|
4966
|
+
}
|
|
4967
|
+
function editTicket(ctx, id, patch) {
|
|
4968
|
+
const current2 = getTicket(ctx, id);
|
|
4969
|
+
assertMutable(current2, "edit");
|
|
4970
|
+
const set = {};
|
|
4971
|
+
if (patch.title !== undefined)
|
|
4972
|
+
set.title = patch.title;
|
|
4973
|
+
if (patch.goal !== undefined)
|
|
4974
|
+
set.goal = patch.goal;
|
|
4975
|
+
if (patch.context !== undefined)
|
|
4976
|
+
set.context = patch.context;
|
|
4977
|
+
if (patch.acceptanceCriteria !== undefined)
|
|
4978
|
+
set.acceptanceCriteria = patch.acceptanceCriteria;
|
|
4979
|
+
if (patch.seams !== undefined)
|
|
4980
|
+
set.seams = patch.seams;
|
|
4981
|
+
if (Object.keys(set).length === 0) {
|
|
4982
|
+
throw new InvalidInputError("nothing to edit \u2014 pass at least one content field");
|
|
4983
|
+
}
|
|
4984
|
+
ctx.db.update(tickets).set(set).where(eq7(tickets.id, id)).run();
|
|
4985
|
+
emit(ctx, current2.featureId, {
|
|
4986
|
+
type: "ticket.edited",
|
|
4987
|
+
message: `ticket ${current2.seq} content edited (${Object.keys(set).join(", ")})`,
|
|
4988
|
+
ticketId: id,
|
|
4989
|
+
data: { fields: Object.keys(set) }
|
|
4990
|
+
});
|
|
4991
|
+
return getTicket(ctx, id);
|
|
4992
|
+
}
|
|
4993
|
+
function cancelTicket(ctx, id, reason) {
|
|
4994
|
+
const current2 = getTicket(ctx, id);
|
|
4995
|
+
assertMutable(current2, "cancel");
|
|
4996
|
+
ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(eq7(tickets.id, id)).run();
|
|
4997
|
+
emit(ctx, current2.featureId, {
|
|
4998
|
+
type: "ticket.cancelled",
|
|
4999
|
+
message: `ticket ${current2.seq} cancelled${reason?.trim() ? `: ${reason.trim()}` : ""}`,
|
|
5000
|
+
ticketId: id,
|
|
5001
|
+
data: { reason: reason ?? null }
|
|
5002
|
+
});
|
|
5003
|
+
return getTicket(ctx, id);
|
|
5004
|
+
}
|
|
5005
|
+
function sweepOrphanedBurning(ctx, featureId, reason) {
|
|
5006
|
+
const orphaned = ctx.db.select().from(tickets).where(and7(eq7(tickets.featureId, featureId), eq7(tickets.status, "burning"))).all().map(rowToTicket);
|
|
5007
|
+
for (const t of orphaned) {
|
|
5008
|
+
updateTicket(ctx, t.id, { status: "failed", error: reason });
|
|
5009
|
+
emit(ctx, featureId, {
|
|
5010
|
+
type: "ticket.failed",
|
|
5011
|
+
message: `ticket ${t.seq} failed: ${reason}`,
|
|
5012
|
+
ticketId: t.id,
|
|
5013
|
+
data: { error: reason, orphaned: true }
|
|
5014
|
+
});
|
|
5015
|
+
}
|
|
5016
|
+
return orphaned.map((t) => ({ ...t, status: "failed", error: reason }));
|
|
5017
|
+
}
|
|
5018
|
+
function updateTicket(ctx, id, patch) {
|
|
5019
|
+
const current2 = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
|
|
5020
|
+
if (!current2)
|
|
5021
|
+
throw new NotFoundError(`ticket ${id} not found`);
|
|
5022
|
+
const set = {};
|
|
5023
|
+
if (patch.status !== undefined)
|
|
5024
|
+
set.status = patch.status;
|
|
5025
|
+
if (patch.commits !== undefined)
|
|
5026
|
+
set.commits = patch.commits;
|
|
5027
|
+
if (patch.error !== undefined)
|
|
5028
|
+
set.error = patch.error;
|
|
5029
|
+
if (patch.attemptBranch !== undefined)
|
|
5030
|
+
set.attemptBranch = patch.attemptBranch;
|
|
5031
|
+
if (patch.conflictFiles !== undefined)
|
|
5032
|
+
set.conflictFiles = patch.conflictFiles;
|
|
5033
|
+
ctx.db.update(tickets).set(set).where(eq7(tickets.id, id)).run();
|
|
5034
|
+
emit(ctx, current2.featureId, {
|
|
5035
|
+
type: "ticket.updated",
|
|
5036
|
+
message: `ticket ${current2.seq} \u2192 ${patch.status ?? current2.status}`,
|
|
5037
|
+
ticketId: id,
|
|
5038
|
+
data: patch
|
|
5039
|
+
});
|
|
5040
|
+
const updated = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
|
|
5041
|
+
return rowToTicket(updated);
|
|
5042
|
+
}
|
|
5043
|
+
var MUTABLE_STATUSES;
|
|
5044
|
+
var init_tickets = __esm(() => {
|
|
5045
|
+
init_src();
|
|
5046
|
+
init_schema();
|
|
5047
|
+
init_errors();
|
|
5048
|
+
init_events();
|
|
5049
|
+
MUTABLE_STATUSES = new Set(["pending", "failed"]);
|
|
5050
|
+
});
|
|
5051
|
+
|
|
5052
|
+
// src/services/gates.ts
|
|
5053
|
+
import { existsSync as existsSync10 } from "fs";
|
|
5054
|
+
function checkGate(ctx, check, feature) {
|
|
5055
|
+
switch (check) {
|
|
5056
|
+
case "decisions-file-exists":
|
|
5057
|
+
return fileGate(ctx, feature, "decisions.md", "run the ideation session to capture decisions first");
|
|
5058
|
+
case "all-waypoints-terminal": {
|
|
5059
|
+
const wps = listByFeature(ctx, feature.id);
|
|
5060
|
+
if (wps.length === 0) {
|
|
5061
|
+
return { satisfied: false, reason: "no waypoints charted yet \u2014 chart the map before converging" };
|
|
5062
|
+
}
|
|
5063
|
+
const open = wps.filter((w) => w.status !== "resolved" && w.status !== "dropped");
|
|
5064
|
+
return open.length === 0 ? { satisfied: true } : {
|
|
5065
|
+
satisfied: false,
|
|
5066
|
+
reason: notYetTerminal("waypoint", open.map((w) => w.status), ["open", "claimed"])
|
|
5067
|
+
};
|
|
5068
|
+
}
|
|
5069
|
+
case "spec-file-exists":
|
|
5070
|
+
return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
|
|
5071
|
+
case "tickets-approved": {
|
|
5072
|
+
const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
|
|
5073
|
+
return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
|
|
5074
|
+
}
|
|
5075
|
+
case "all-tickets-terminal": {
|
|
5076
|
+
const tickets2 = listByFeature2(ctx, feature.id);
|
|
5077
|
+
if (tickets2.length === 0)
|
|
5078
|
+
return { satisfied: false, reason: "no tickets have run yet" };
|
|
5079
|
+
const open = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
|
|
5080
|
+
return open.length === 0 ? { satisfied: true } : {
|
|
5081
|
+
satisfied: false,
|
|
5082
|
+
reason: notYetTerminal("ticket", open.map((t) => t.status), ["pending", "burning"])
|
|
5083
|
+
};
|
|
5084
|
+
}
|
|
5085
|
+
case "human-merge":
|
|
5086
|
+
return { satisfied: false, reason: "use the Merge button to ship" };
|
|
5087
|
+
}
|
|
5088
|
+
}
|
|
5089
|
+
function notYetTerminal(noun, statuses, order) {
|
|
5090
|
+
const counts = new Map;
|
|
5091
|
+
for (const s of statuses)
|
|
5092
|
+
counts.set(s, (counts.get(s) ?? 0) + 1);
|
|
5093
|
+
const ordered = [
|
|
5094
|
+
...order.filter((s) => counts.has(s)),
|
|
5095
|
+
...[...counts.keys()].filter((s) => !order.includes(s))
|
|
5096
|
+
];
|
|
5097
|
+
const breakdown = ordered.map((s) => `${counts.get(s)} ${s}`).join(", ");
|
|
5098
|
+
return `${statuses.length} ${statuses.length === 1 ? noun : `${noun}s`} not yet terminal (${breakdown})`;
|
|
5099
|
+
}
|
|
5100
|
+
function fileGate(ctx, feature, fileName, reason) {
|
|
5101
|
+
const project = projectForFeature(ctx, feature);
|
|
5102
|
+
return existsSync10(featureDocPath(project, feature, fileName)) ? { satisfied: true } : { satisfied: false, reason };
|
|
5103
|
+
}
|
|
5104
|
+
function overrideGate(ctx, featureId, gate, reason) {
|
|
5105
|
+
const feature = getFeatureRow(ctx, featureId);
|
|
5106
|
+
ctx.db.insert(gateOverrides).values({ featureId, gate, reason, ts: Date.now() }).run();
|
|
5107
|
+
emit(ctx, featureId, {
|
|
5108
|
+
type: "gate.overridden",
|
|
5109
|
+
message: `gate ${gate} overridden: ${reason}`,
|
|
5110
|
+
data: { gate, reason }
|
|
5111
|
+
});
|
|
5112
|
+
const next = nextPhase(feature);
|
|
5113
|
+
if (next)
|
|
5114
|
+
return setPhase(ctx, featureId, next, "phase.advanced", `advanced to ${next} (override)`);
|
|
5115
|
+
return feature;
|
|
5116
|
+
}
|
|
5117
|
+
var init_gates = __esm(() => {
|
|
5118
|
+
init_src();
|
|
5119
|
+
init_schema();
|
|
5120
|
+
init_events();
|
|
5121
|
+
init_feature_docs();
|
|
5122
|
+
init_repo();
|
|
5123
|
+
init_tickets();
|
|
5124
|
+
init_waypoints();
|
|
5125
|
+
});
|
|
5126
|
+
|
|
5127
|
+
// src/services/knowledge.ts
|
|
5128
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync3, writeFileSync } from "fs";
|
|
5129
|
+
import { join as join10, relative, resolve as resolve6, sep } from "path";
|
|
5130
|
+
function scaffoldDocs(ctx, feature) {
|
|
5131
|
+
const project = projectForFeature(ctx, feature);
|
|
5132
|
+
const dir = featureDocsDir(project, feature);
|
|
5133
|
+
mkdirSync5(dir, { recursive: true });
|
|
5134
|
+
const briefPath = featureDocPath(project, feature, "brief.md");
|
|
5135
|
+
if (!existsSync11(briefPath)) {
|
|
5136
|
+
const created = new Date().toISOString();
|
|
5137
|
+
const brief = [
|
|
5138
|
+
`# ${feature.title}`,
|
|
5139
|
+
"",
|
|
5140
|
+
feature.oneLiner,
|
|
5141
|
+
"",
|
|
5142
|
+
`- Slug: ${feature.slug}`,
|
|
5143
|
+
`- Created: ${created}`,
|
|
5144
|
+
""
|
|
5145
|
+
].join(`
|
|
5146
|
+
`);
|
|
5147
|
+
writeFileSync(briefPath, brief, "utf8");
|
|
5148
|
+
}
|
|
5149
|
+
if (feature.mapped) {
|
|
5150
|
+
scaffoldMapDoc(project, feature);
|
|
5151
|
+
}
|
|
5152
|
+
emit(ctx, feature.id, {
|
|
5153
|
+
type: "docs.scaffolded",
|
|
5154
|
+
message: feature.mapped ? `scaffolded docs/features/${feature.slug}/{brief,map}.md` : `scaffolded docs/features/${feature.slug}/brief.md`
|
|
5155
|
+
});
|
|
5156
|
+
}
|
|
5157
|
+
function scaffoldMapDoc(project, feature, seed) {
|
|
5158
|
+
mkdirSync5(featureDocsDir(project, feature), { recursive: true });
|
|
5159
|
+
const mapPath = featureDocPath(project, feature, "map.md");
|
|
5160
|
+
if (existsSync11(mapPath))
|
|
5161
|
+
return;
|
|
5162
|
+
writeFileSync(mapPath, mapDocBody(feature, seed), "utf8");
|
|
5163
|
+
}
|
|
5164
|
+
function mapDocBody(feature, seed) {
|
|
5165
|
+
const seeded = {
|
|
5166
|
+
Destination: seed?.destination?.trim(),
|
|
5167
|
+
Notes: seed?.notes?.trim()
|
|
5168
|
+
};
|
|
5169
|
+
const lines = [`# ${feature.title} \u2014 map`, ""];
|
|
5170
|
+
for (const section of MAP_SECTIONS) {
|
|
5171
|
+
lines.push(`## ${section}`, "");
|
|
5172
|
+
const body = seeded[section];
|
|
5173
|
+
if (body)
|
|
5174
|
+
lines.push(body, "");
|
|
5175
|
+
}
|
|
5176
|
+
return lines.join(`
|
|
5177
|
+
`);
|
|
5178
|
+
}
|
|
5179
|
+
function listDocs(ctx, feature) {
|
|
5180
|
+
const project = projectForFeature(ctx, feature);
|
|
5181
|
+
const dir = featureDocsDir(project, feature);
|
|
5182
|
+
if (!existsSync11(dir))
|
|
5183
|
+
return [];
|
|
5184
|
+
return readdirSync3(dir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => ({ relPath: e.name, title: readTitle(join10(dir, e.name), e.name) })).sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
5185
|
+
}
|
|
5186
|
+
function readDoc(ctx, feature, relPath) {
|
|
5187
|
+
const project = projectForFeature(ctx, feature);
|
|
5188
|
+
const dir = featureDocsDir(project, feature);
|
|
5189
|
+
const root = resolve6(dir);
|
|
5190
|
+
const target = resolve6(dir, relPath);
|
|
5191
|
+
const rel = relative(root, target);
|
|
5192
|
+
if (rel === "" || rel.startsWith("..") || rel.split(sep).includes("..")) {
|
|
5193
|
+
throw new InvalidInputError(`doc path escapes the feature docs dir: ${relPath}`);
|
|
5194
|
+
}
|
|
5195
|
+
if (!existsSync11(target))
|
|
5196
|
+
throw new NotFoundError(`doc not found: ${relPath}`);
|
|
5197
|
+
return { content: readFileSync5(target, "utf8") };
|
|
5198
|
+
}
|
|
5199
|
+
function readTitle(path, fallback) {
|
|
5200
|
+
try {
|
|
5201
|
+
const firstLine = readFileSync5(path, "utf8").split(`
|
|
5202
|
+
`, 1)[0] ?? "";
|
|
5203
|
+
const heading = firstLine.match(/^#\s+(.+)$/);
|
|
5204
|
+
return heading ? heading[1].trim() : fallback;
|
|
5205
|
+
} catch {
|
|
5206
|
+
return fallback;
|
|
5207
|
+
}
|
|
5208
|
+
}
|
|
5209
|
+
var MAP_SECTIONS;
|
|
5210
|
+
var init_knowledge = __esm(() => {
|
|
5211
|
+
init_errors();
|
|
5212
|
+
init_events();
|
|
5213
|
+
init_feature_docs();
|
|
5214
|
+
init_repo();
|
|
5215
|
+
MAP_SECTIONS = [
|
|
5216
|
+
"Destination",
|
|
5217
|
+
"Notes",
|
|
5218
|
+
"Not yet specified",
|
|
5219
|
+
"Out of scope"
|
|
5220
|
+
];
|
|
5221
|
+
});
|
|
5222
|
+
|
|
5223
|
+
// src/pty/end-session.ts
|
|
5224
|
+
function endSession(ctx, sessionId) {
|
|
5225
|
+
const registry = ptyRegistry();
|
|
5226
|
+
const killed = registry.kill(sessionId);
|
|
5227
|
+
registry.remove(sessionId);
|
|
5228
|
+
const session = markSessionEnded(ctx, sessionId);
|
|
5229
|
+
if (session) {
|
|
5230
|
+
releaseForSession(ctx, sessionId);
|
|
5231
|
+
emitForSession(ctx, session, {
|
|
5232
|
+
type: "session.ended",
|
|
5233
|
+
message: "session ended by user",
|
|
5234
|
+
data: { sessionId, killed }
|
|
5235
|
+
});
|
|
5236
|
+
} else {
|
|
5237
|
+
const existing = getSessionRow(ctx, sessionId);
|
|
5238
|
+
if (!existing)
|
|
5239
|
+
return { sessionId, ok: false, killed };
|
|
5240
|
+
}
|
|
5241
|
+
return { sessionId, ok: true, killed };
|
|
5242
|
+
}
|
|
5243
|
+
var init_end_session = __esm(() => {
|
|
5244
|
+
init_sessions();
|
|
5245
|
+
init_events();
|
|
5246
|
+
init_waypoints();
|
|
5247
|
+
init_registry();
|
|
4143
5248
|
});
|
|
4144
5249
|
|
|
4145
5250
|
// src/workflows/research.ts
|
|
4146
|
-
import { existsSync as
|
|
4147
|
-
import { dirname as
|
|
4148
|
-
import { fileURLToPath as
|
|
4149
|
-
import { run as
|
|
5251
|
+
import { existsSync as existsSync12, mkdirSync as mkdirSync6, readFileSync as readFileSync6, readdirSync as readdirSync4 } from "fs";
|
|
5252
|
+
import { dirname as dirname5, join as join11 } from "path";
|
|
5253
|
+
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
5254
|
+
import { run as run3 } from "@ai-hero/sandcastle";
|
|
4150
5255
|
import { docker as docker2 } from "@ai-hero/sandcastle/sandboxes/docker";
|
|
4151
5256
|
import { noSandbox as noSandbox2 } from "@ai-hero/sandcastle/sandboxes/no-sandbox";
|
|
4152
5257
|
function waypointSlug(waypoint) {
|
|
@@ -4163,7 +5268,7 @@ async function researchRun(ctx, deps) {
|
|
|
4163
5268
|
return { status: "failed", summary: "no waypoint to research" };
|
|
4164
5269
|
}
|
|
4165
5270
|
if (deps.config.sandbox === "docker" && !deps.hasAuthToken) {
|
|
4166
|
-
ctx.emitEvent({ type:
|
|
5271
|
+
ctx.emitEvent({ type: AUTH_MISSING_EVENT3, message: AUTH_MISSING_MESSAGE3 });
|
|
4167
5272
|
return { status: "failed", summary: "research aborted: auth token missing" };
|
|
4168
5273
|
}
|
|
4169
5274
|
ctx.emitEvent({
|
|
@@ -4184,23 +5289,23 @@ async function researchRun(ctx, deps) {
|
|
|
4184
5289
|
}
|
|
4185
5290
|
ctx.emitEvent({
|
|
4186
5291
|
type: "research.failed",
|
|
4187
|
-
message: `waypoint ${waypoint.seq} research failed: ${
|
|
5292
|
+
message: `waypoint ${waypoint.seq} research failed: ${errorHeadline3(outcome.error)}`,
|
|
4188
5293
|
data: { waypointId: waypoint.id, error: outcome.error }
|
|
4189
5294
|
});
|
|
4190
5295
|
return { status: "failed", summary: outcome.error };
|
|
4191
5296
|
}
|
|
4192
|
-
function
|
|
5297
|
+
function errorHeadline3(s) {
|
|
4193
5298
|
const lines = s.split(`
|
|
4194
5299
|
`).map((l) => l.trim()).filter(Boolean);
|
|
4195
5300
|
const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
|
|
4196
5301
|
return causes.at(-1) ?? lines[0] ?? "";
|
|
4197
5302
|
}
|
|
4198
|
-
function createResearchStreamThrottle(
|
|
4199
|
-
return createStreamThrottle((e) =>
|
|
5303
|
+
function createResearchStreamThrottle(emit3, opts = {}) {
|
|
5304
|
+
return createStreamThrottle((e) => emit3({ ...e, type: e.type.replace(/^burn\./, "research.") }), opts);
|
|
4200
5305
|
}
|
|
4201
5306
|
function renderResearchPrompt(template, values) {
|
|
4202
5307
|
let out = template;
|
|
4203
|
-
for (const key of
|
|
5308
|
+
for (const key of PLACEHOLDERS2) {
|
|
4204
5309
|
out = out.split(`{{${key}}}`).join(values[key]);
|
|
4205
5310
|
}
|
|
4206
5311
|
return out;
|
|
@@ -4215,32 +5320,32 @@ function interpretResearchResult(result, docRelPath) {
|
|
|
4215
5320
|
return { status: "failed", error: "agent produced no commits \u2014 no research summary landed" };
|
|
4216
5321
|
}
|
|
4217
5322
|
function researchTemplatePath() {
|
|
4218
|
-
const here =
|
|
4219
|
-
return
|
|
5323
|
+
const here = dirname5(fileURLToPath4(import.meta.url));
|
|
5324
|
+
return join11(resolveSkillsRoot(here), "burner", "research-waypoint.md");
|
|
4220
5325
|
}
|
|
4221
5326
|
function readDocsDigestFromDisk2(projectId, slug) {
|
|
4222
5327
|
const worktree = worktreeDir(projectId, slug);
|
|
4223
|
-
if (!
|
|
5328
|
+
if (!existsSync12(worktree))
|
|
4224
5329
|
return "_No talk worktree on disk \u2014 docs digest skipped._";
|
|
4225
|
-
const docsDir =
|
|
4226
|
-
if (!
|
|
5330
|
+
const docsDir = join11(worktree, ...featureDocsRel(slug).split("/"));
|
|
5331
|
+
if (!existsSync12(docsDir))
|
|
4227
5332
|
return "_No docs/features dir in the talk worktree \u2014 docs digest skipped._";
|
|
4228
|
-
const files = readdirSync4(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content:
|
|
5333
|
+
const files = readdirSync4(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content: readFileSync6(join11(docsDir, e.name), "utf8") }));
|
|
4229
5334
|
return buildDocsDigest(files);
|
|
4230
5335
|
}
|
|
4231
5336
|
async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
|
|
4232
5337
|
const { project, feature } = ctx;
|
|
4233
5338
|
const docRelPath = researchDocRel(feature.slug, waypoint);
|
|
4234
5339
|
const tempBranch = researchBranchName(feature.slug, waypoint.seq, newId("b").slice(2, 10));
|
|
4235
|
-
const template =
|
|
5340
|
+
const template = readFileSync6(researchTemplatePath(), "utf8");
|
|
4236
5341
|
const prompt = renderResearchPrompt(template, {
|
|
4237
5342
|
WAYPOINT_JSON: buildWaypointJson(waypoint),
|
|
4238
5343
|
FEATURE_BRIEF: buildFeatureBrief(feature),
|
|
4239
5344
|
DOCS_DIGEST: readDocsDigestFromDisk2(project.id, feature.slug),
|
|
4240
5345
|
RESEARCH_DOC_PATH: docRelPath
|
|
4241
5346
|
});
|
|
4242
|
-
|
|
4243
|
-
const logFilePath =
|
|
5347
|
+
mkdirSync6(logsDir(), { recursive: true });
|
|
5348
|
+
const logFilePath = join11(logsDir(), `research-${feature.id}-${waypoint.seq}.log`);
|
|
4244
5349
|
const throttle = createResearchStreamThrottle((e) => ctx.emitEvent(e));
|
|
4245
5350
|
const runOptions = {
|
|
4246
5351
|
agent: buildBurnAgent(config2, token, model),
|
|
@@ -4255,7 +5360,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
|
|
|
4255
5360
|
let result;
|
|
4256
5361
|
let salvaged = [];
|
|
4257
5362
|
try {
|
|
4258
|
-
result = await
|
|
5363
|
+
result = await run3(runOptions);
|
|
4259
5364
|
} catch (err) {
|
|
4260
5365
|
throttle.flush();
|
|
4261
5366
|
if (ctx.signal.aborted)
|
|
@@ -4267,7 +5372,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
|
|
|
4267
5372
|
const removed = await cleanupBurnWorktree(project.repoPath, tempBranch);
|
|
4268
5373
|
ctx.emitEvent({
|
|
4269
5374
|
type: "burn.worktree.teardown-failed",
|
|
4270
|
-
message: `waypoint ${waypoint.seq}: agent finished but sandcastle could not remove its worktree (${
|
|
5375
|
+
message: `waypoint ${waypoint.seq}: agent finished but sandcastle could not remove its worktree (${errorHeadline3(msg)})${removed ? " \u2014 cleaned up" : " \u2014 left on disk"}; landing the ${salvaged.length} commit(s) anyway`,
|
|
4271
5376
|
data: { tempBranch, error: msg, cleanedUp: removed }
|
|
4272
5377
|
});
|
|
4273
5378
|
}
|
|
@@ -4280,7 +5385,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
|
|
|
4280
5385
|
const detail = merge.error ?? "merge failed";
|
|
4281
5386
|
ctx.emitEvent({
|
|
4282
5387
|
type: merge.conflict ? "merge.conflict.needs-human" : "research.merge_failed",
|
|
4283
|
-
message: merge.conflict ? `research branch ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then resolve the waypoint` : `could not land ${tempBranch} on ${feature.branch}: ${
|
|
5388
|
+
message: merge.conflict ? `research branch ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then resolve the waypoint` : `could not land ${tempBranch} on ${feature.branch}: ${errorHeadline3(detail)} \u2014 the branch is preserved for manual recovery`,
|
|
4284
5389
|
data: { tempBranch, featureBranch: feature.branch, conflict: merge.conflict ?? false, error: detail }
|
|
4285
5390
|
});
|
|
4286
5391
|
return {
|
|
@@ -4302,9 +5407,9 @@ function resolveResearchDeps(ctx) {
|
|
|
4302
5407
|
}
|
|
4303
5408
|
function readTokenFromEnvFile2(path) {
|
|
4304
5409
|
let fromFile;
|
|
4305
|
-
if (
|
|
5410
|
+
if (existsSync12(path)) {
|
|
4306
5411
|
try {
|
|
4307
|
-
fromFile = parseEnvFile(
|
|
5412
|
+
fromFile = parseEnvFile(readFileSync6(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
|
|
4308
5413
|
} catch {
|
|
4309
5414
|
fromFile = undefined;
|
|
4310
5415
|
}
|
|
@@ -4312,7 +5417,7 @@ function readTokenFromEnvFile2(path) {
|
|
|
4312
5417
|
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
4313
5418
|
return token && token.length > 0 ? token : undefined;
|
|
4314
5419
|
}
|
|
4315
|
-
var
|
|
5420
|
+
var AUTH_MISSING_EVENT3 = "auth.missing", AUTH_MISSING_MESSAGE3 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS2, research;
|
|
4316
5421
|
var init_research = __esm(() => {
|
|
4317
5422
|
init_src();
|
|
4318
5423
|
init_config_load();
|
|
@@ -4320,7 +5425,7 @@ var init_research = __esm(() => {
|
|
|
4320
5425
|
init_skills_root();
|
|
4321
5426
|
init_git();
|
|
4322
5427
|
init_ticket_burner();
|
|
4323
|
-
|
|
5428
|
+
PLACEHOLDERS2 = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
|
|
4324
5429
|
research = {
|
|
4325
5430
|
id: "research",
|
|
4326
5431
|
run: (ctx) => researchRun(ctx, resolveResearchDeps(ctx))
|
|
@@ -4342,12 +5447,12 @@ var init_registry2 = __esm(() => {
|
|
|
4342
5447
|
});
|
|
4343
5448
|
|
|
4344
5449
|
// src/workflows/runner.ts
|
|
4345
|
-
import { eq as
|
|
5450
|
+
import { eq as eq8 } from "drizzle-orm";
|
|
4346
5451
|
function workflowClaimsFeatureBranch(workflowId) {
|
|
4347
5452
|
return BRANCH_CLAIMING.has(workflowId);
|
|
4348
5453
|
}
|
|
4349
5454
|
function isRunActive(runId) {
|
|
4350
|
-
return
|
|
5455
|
+
return controllers2.has(runId);
|
|
4351
5456
|
}
|
|
4352
5457
|
async function startRun(ctx, featureId, workflowId, opts = {}) {
|
|
4353
5458
|
const feature = getFeatureRow(ctx, featureId);
|
|
@@ -4371,7 +5476,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
|
|
|
4371
5476
|
claim(ctx, opts.claimWaypointId, runId);
|
|
4372
5477
|
} catch (e) {
|
|
4373
5478
|
const summary = e instanceof Error ? e.message : "claim failed";
|
|
4374
|
-
ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(
|
|
5479
|
+
ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(eq8(runs.id, runId)).run();
|
|
4375
5480
|
throw e;
|
|
4376
5481
|
}
|
|
4377
5482
|
}
|
|
@@ -4382,7 +5487,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
|
|
|
4382
5487
|
data: { workflow: workflowId }
|
|
4383
5488
|
});
|
|
4384
5489
|
const controller = new AbortController;
|
|
4385
|
-
|
|
5490
|
+
controllers2.set(runId, controller);
|
|
4386
5491
|
const talkWorktree = worktreeDir(project.id, feature.slug);
|
|
4387
5492
|
const talkDetached = workflowClaimsFeatureBranch(workflowId) ? await detachWorktree(talkWorktree) : false;
|
|
4388
5493
|
const wctx = {
|
|
@@ -4404,7 +5509,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
|
|
|
4404
5509
|
input: opts.input,
|
|
4405
5510
|
modelOverride: opts.modelOverride,
|
|
4406
5511
|
resolveWaypoint: (id, disposition, summary) => {
|
|
4407
|
-
|
|
5512
|
+
resolve3(ctx, id, disposition, summary);
|
|
4408
5513
|
},
|
|
4409
5514
|
signal: controller.signal
|
|
4410
5515
|
};
|
|
@@ -4415,7 +5520,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
|
|
|
4415
5520
|
return { runId, done };
|
|
4416
5521
|
}
|
|
4417
5522
|
function cancelRun(runId) {
|
|
4418
|
-
|
|
5523
|
+
controllers2.get(runId)?.abort();
|
|
4419
5524
|
}
|
|
4420
5525
|
async function executeRun(ctx, runId, featureId, workflow2, runPromise, controller, cleanup) {
|
|
4421
5526
|
let status = "failed";
|
|
@@ -4434,9 +5539,9 @@ async function executeRun(ctx, runId, featureId, workflow2, runPromise, controll
|
|
|
4434
5539
|
}
|
|
4435
5540
|
emit(ctx, featureId, { type: "run.error", message: summary, runId });
|
|
4436
5541
|
} finally {
|
|
4437
|
-
|
|
5542
|
+
controllers2.delete(runId);
|
|
4438
5543
|
}
|
|
4439
|
-
ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(
|
|
5544
|
+
ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(eq8(runs.id, runId)).run();
|
|
4440
5545
|
releaseForSession(ctx, runId);
|
|
4441
5546
|
if (workflowClaimsFeatureBranch(workflow2)) {
|
|
4442
5547
|
sweepOrphanedBurning(ctx, featureId, `orphaned \u2014 the run ended (${status}) while it was burning`);
|
|
@@ -4466,7 +5571,7 @@ function maybeAutoAdvance(ctx, featureId) {
|
|
|
4466
5571
|
if (next)
|
|
4467
5572
|
setPhase(ctx, featureId, next, "phase.advanced", "auto-advanced to review after successful run");
|
|
4468
5573
|
}
|
|
4469
|
-
var
|
|
5574
|
+
var controllers2, BRANCH_CLAIMING;
|
|
4470
5575
|
var init_runner = __esm(() => {
|
|
4471
5576
|
init_src();
|
|
4472
5577
|
init_paths();
|
|
@@ -4479,12 +5584,12 @@ var init_runner = __esm(() => {
|
|
|
4479
5584
|
init_tickets();
|
|
4480
5585
|
init_waypoints();
|
|
4481
5586
|
init_registry2();
|
|
4482
|
-
|
|
5587
|
+
controllers2 = new Map;
|
|
4483
5588
|
BRANCH_CLAIMING = new Set(["ticket-burner"]);
|
|
4484
5589
|
});
|
|
4485
5590
|
|
|
4486
5591
|
// src/services/features.ts
|
|
4487
|
-
import { desc as
|
|
5592
|
+
import { desc as desc4, eq as eq9 } from "drizzle-orm";
|
|
4488
5593
|
import { rmSync as rmSync2 } from "fs";
|
|
4489
5594
|
async function createFeature(ctx, input) {
|
|
4490
5595
|
const project = requireProjectById(ctx, input.projectId);
|
|
@@ -4547,7 +5652,7 @@ function getFeatureFull(ctx, id) {
|
|
|
4547
5652
|
};
|
|
4548
5653
|
}
|
|
4549
5654
|
function list(ctx, projectId) {
|
|
4550
|
-
const rows = ctx.db.select().from(features).where(
|
|
5655
|
+
const rows = ctx.db.select().from(features).where(eq9(features.projectId, projectId)).orderBy(desc4(features.createdAt)).all();
|
|
4551
5656
|
return rows.map((row) => {
|
|
4552
5657
|
const feature = rowToFeature(row);
|
|
4553
5658
|
const tickets2 = listByFeature2(ctx, feature.id);
|
|
@@ -4692,7 +5797,7 @@ function escalateToMap(ctx, featureId, input) {
|
|
|
4692
5797
|
return { ok: true, warning: `feature ${feature.slug} is already mapped \u2014 no changes made` };
|
|
4693
5798
|
}
|
|
4694
5799
|
const project = projectForFeature(ctx, feature);
|
|
4695
|
-
ctx.db.update(features).set({ mapped: true }).where(
|
|
5800
|
+
ctx.db.update(features).set({ mapped: true }).where(eq9(features.id, featureId)).run();
|
|
4696
5801
|
scaffoldMapDoc(project, { ...feature, mapped: true }, input);
|
|
4697
5802
|
emit(ctx, featureId, {
|
|
4698
5803
|
type: "feature.escalated",
|
|
@@ -4709,7 +5814,7 @@ function archiveFeature(ctx, featureId) {
|
|
|
4709
5814
|
const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
|
|
4710
5815
|
if (live)
|
|
4711
5816
|
endSession(ctx, live.id);
|
|
4712
|
-
ctx.db.update(features).set({ status: "archived" }).where(
|
|
5817
|
+
ctx.db.update(features).set({ status: "archived" }).where(eq9(features.id, featureId)).run();
|
|
4713
5818
|
emit(ctx, featureId, {
|
|
4714
5819
|
type: "feature.archived",
|
|
4715
5820
|
message: `feature ${feature.slug} archived`,
|
|
@@ -4723,7 +5828,7 @@ function unarchiveFeature(ctx, featureId) {
|
|
|
4723
5828
|
throw new GateError(`feature ${feature.slug} is not archived`);
|
|
4724
5829
|
}
|
|
4725
5830
|
const restored = feature.phase === "shipped" ? "shipped" : "active";
|
|
4726
|
-
ctx.db.update(features).set({ status: restored }).where(
|
|
5831
|
+
ctx.db.update(features).set({ status: restored }).where(eq9(features.id, featureId)).run();
|
|
4727
5832
|
emit(ctx, featureId, {
|
|
4728
5833
|
type: "feature.unarchived",
|
|
4729
5834
|
message: `feature ${feature.slug} unarchived (${restored})`,
|
|
@@ -4737,9 +5842,9 @@ async function deleteFeature(ctx, featureId) {
|
|
|
4737
5842
|
throw new GateError(`feature ${feature.slug} is shipped \u2014 archive it instead (delete is for non-shipped features)`);
|
|
4738
5843
|
}
|
|
4739
5844
|
const project = projectForFeature(ctx, feature);
|
|
4740
|
-
for (const
|
|
4741
|
-
if (
|
|
4742
|
-
cancelRun(
|
|
5845
|
+
for (const run4 of listRunsByFeature(ctx, featureId)) {
|
|
5846
|
+
if (run4.status === "running")
|
|
5847
|
+
cancelRun(run4.id);
|
|
4743
5848
|
}
|
|
4744
5849
|
const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
|
|
4745
5850
|
if (live)
|
|
@@ -4763,13 +5868,13 @@ async function deleteFeature(ctx, featureId) {
|
|
|
4763
5868
|
return { ok: true, slug: feature.slug };
|
|
4764
5869
|
}
|
|
4765
5870
|
function deleteFeatureRows(ctx, featureId) {
|
|
4766
|
-
ctx.db.delete(tickets).where(
|
|
4767
|
-
ctx.db.delete(sessions).where(
|
|
4768
|
-
ctx.db.delete(runs).where(
|
|
4769
|
-
ctx.db.delete(events).where(
|
|
4770
|
-
ctx.db.delete(gateOverrides).where(
|
|
4771
|
-
ctx.db.delete(waypoints).where(
|
|
4772
|
-
ctx.db.delete(features).where(
|
|
5871
|
+
ctx.db.delete(tickets).where(eq9(tickets.featureId, featureId)).run();
|
|
5872
|
+
ctx.db.delete(sessions).where(eq9(sessions.featureId, featureId)).run();
|
|
5873
|
+
ctx.db.delete(runs).where(eq9(runs.featureId, featureId)).run();
|
|
5874
|
+
ctx.db.delete(events).where(eq9(events.featureId, featureId)).run();
|
|
5875
|
+
ctx.db.delete(gateOverrides).where(eq9(gateOverrides.featureId, featureId)).run();
|
|
5876
|
+
ctx.db.delete(waypoints).where(eq9(waypoints.featureId, featureId)).run();
|
|
5877
|
+
ctx.db.delete(features).where(eq9(features.id, featureId)).run();
|
|
4773
5878
|
}
|
|
4774
5879
|
function gateState(ctx, feature) {
|
|
4775
5880
|
const gate = nextGate(feature);
|
|
@@ -4780,7 +5885,7 @@ function gateState(ctx, feature) {
|
|
|
4780
5885
|
}
|
|
4781
5886
|
function uniqueSlug(ctx, projectId, title) {
|
|
4782
5887
|
const base = slugify(title);
|
|
4783
|
-
const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(
|
|
5888
|
+
const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(eq9(features.projectId, projectId)).all().map((r) => r.slug));
|
|
4784
5889
|
if (!existing.has(base))
|
|
4785
5890
|
return base;
|
|
4786
5891
|
let n = 2;
|
|
@@ -4812,7 +5917,7 @@ var init_features = __esm(() => {
|
|
|
4812
5917
|
import { StreamableHTTPTransport } from "@hono/mcp";
|
|
4813
5918
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4814
5919
|
import { Hono } from "hono";
|
|
4815
|
-
import * as
|
|
5920
|
+
import * as z4 from "zod";
|
|
4816
5921
|
function headerSessionId(extra) {
|
|
4817
5922
|
const headers = extra.requestInfo?.headers;
|
|
4818
5923
|
if (!headers)
|
|
@@ -4829,8 +5934,14 @@ function resolveSession(ctx, sessionId) {
|
|
|
4829
5934
|
}
|
|
4830
5935
|
return mostRecentLiveSession(ctx);
|
|
4831
5936
|
}
|
|
5937
|
+
function requireFeatureId(session) {
|
|
5938
|
+
if (!session.featureId) {
|
|
5939
|
+
throw new GateError(`this tool needs a feature, and a ${session.kind} session is project-scoped. ` + "Use record_finding to store what you establish about the project.");
|
|
5940
|
+
}
|
|
5941
|
+
return session.featureId;
|
|
5942
|
+
}
|
|
4832
5943
|
function toolGetFeatureContext(ctx, session) {
|
|
4833
|
-
const feature = getFeatureRow(ctx, session
|
|
5944
|
+
const feature = getFeatureRow(ctx, requireFeatureId(session));
|
|
4834
5945
|
const docs = listDocs(ctx, feature).map((d) => {
|
|
4835
5946
|
try {
|
|
4836
5947
|
return { relPath: d.relPath, content: readDoc(ctx, feature, d.relPath).content };
|
|
@@ -4852,17 +5963,17 @@ function toolGetFeatureContext(ctx, session) {
|
|
|
4852
5963
|
return context;
|
|
4853
5964
|
}
|
|
4854
5965
|
function toolResolveWaypoint(ctx, _session, input) {
|
|
4855
|
-
|
|
5966
|
+
resolve3(ctx, input.id, input.disposition, input.summary);
|
|
4856
5967
|
return { ok: true };
|
|
4857
5968
|
}
|
|
4858
5969
|
function toolEmitTickets(ctx, session, input) {
|
|
4859
|
-
const feature = getFeatureRow(ctx, session
|
|
5970
|
+
const feature = getFeatureRow(ctx, requireFeatureId(session));
|
|
4860
5971
|
const stored = storeTickets(ctx, feature.id, input.tickets);
|
|
4861
5972
|
return { stored: stored.length, ids: stored.map((t) => t.id) };
|
|
4862
5973
|
}
|
|
4863
5974
|
function requireOwnTicket(ctx, session, ticketId) {
|
|
4864
5975
|
const ticket = getTicket(ctx, ticketId);
|
|
4865
|
-
if (ticket.featureId !== session
|
|
5976
|
+
if (ticket.featureId !== requireFeatureId(session)) {
|
|
4866
5977
|
throw new GateError(`ticket ${ticketId} does not belong to this session's feature`);
|
|
4867
5978
|
}
|
|
4868
5979
|
return ticket;
|
|
@@ -4877,10 +5988,10 @@ function toolCancelTicket(ctx, session, input) {
|
|
|
4877
5988
|
return { ok: true, ticket: cancelTicket(ctx, input.id, input.reason) };
|
|
4878
5989
|
}
|
|
4879
5990
|
function toolEscalateToMap(ctx, session, input) {
|
|
4880
|
-
return escalateToMap(ctx, session
|
|
5991
|
+
return escalateToMap(ctx, requireFeatureId(session), input);
|
|
4881
5992
|
}
|
|
4882
5993
|
function toolEmitWaypoints(ctx, session, input) {
|
|
4883
|
-
const feature = getFeatureRow(ctx, session
|
|
5994
|
+
const feature = getFeatureRow(ctx, requireFeatureId(session));
|
|
4884
5995
|
if (!feature.mapped) {
|
|
4885
5996
|
throw new GateError("feature is not mapped \u2014 call escalate_to_map before emitting waypoints");
|
|
4886
5997
|
}
|
|
@@ -4888,7 +5999,7 @@ function toolEmitWaypoints(ctx, session, input) {
|
|
|
4888
5999
|
return { stored: stored.length, ids: stored.map((w) => w.id) };
|
|
4889
6000
|
}
|
|
4890
6001
|
function toolRecordEvent(ctx, session, input) {
|
|
4891
|
-
emit(ctx, session
|
|
6002
|
+
emit(ctx, requireFeatureId(session), {
|
|
4892
6003
|
type: input.type,
|
|
4893
6004
|
message: input.message,
|
|
4894
6005
|
data: { source: "mcp" }
|
|
@@ -4896,7 +6007,7 @@ function toolRecordEvent(ctx, session, input) {
|
|
|
4896
6007
|
return { ok: true };
|
|
4897
6008
|
}
|
|
4898
6009
|
function toolCompletePhase(ctx, session, input) {
|
|
4899
|
-
const feature = getFeatureRow(ctx, session
|
|
6010
|
+
const feature = getFeatureRow(ctx, requireFeatureId(session));
|
|
4900
6011
|
emit(ctx, feature.id, {
|
|
4901
6012
|
type: "phase.complete_requested",
|
|
4902
6013
|
message: `session marked phase '${input.phase}' complete`,
|
|
@@ -4925,13 +6036,46 @@ async function commitDocsCheckpoint(ctx, session, message) {
|
|
|
4925
6036
|
try {
|
|
4926
6037
|
await commitDocs(session.worktreePath, message);
|
|
4927
6038
|
} catch (e) {
|
|
4928
|
-
emit(ctx, session
|
|
6039
|
+
emit(ctx, requireFeatureId(session), {
|
|
4929
6040
|
type: "git.commit_pending",
|
|
4930
6041
|
message: isNotImplemented(e) ? "docs checkpoint skipped (git service pending)" : `docs checkpoint failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
4931
6042
|
data: { message }
|
|
4932
6043
|
});
|
|
4933
6044
|
}
|
|
4934
6045
|
}
|
|
6046
|
+
function requireProject(ctx, session) {
|
|
6047
|
+
if (!session.projectId) {
|
|
6048
|
+
throw new GateError(`record_finding is for a project-scoped preparation session; this is a ${session.kind} session.`);
|
|
6049
|
+
}
|
|
6050
|
+
const project = getProjectById(ctx, session.projectId);
|
|
6051
|
+
if (!project)
|
|
6052
|
+
throw new GateError(`project ${session.projectId} not found`);
|
|
6053
|
+
return project;
|
|
6054
|
+
}
|
|
6055
|
+
function toolRecordFinding(ctx, session, input) {
|
|
6056
|
+
const project = requireProject(ctx, session);
|
|
6057
|
+
const source = input.userSupplied ? "human" : "session";
|
|
6058
|
+
if (!isOverwritable(ctx, project.id, input.key) && !input.userSupplied) {
|
|
6059
|
+
return {
|
|
6060
|
+
ok: true,
|
|
6061
|
+
key: input.key,
|
|
6062
|
+
source,
|
|
6063
|
+
skipped: `${input.key} was set by a human and is not auto-overwritable \u2014 ask them to clear it first if it should change`
|
|
6064
|
+
};
|
|
6065
|
+
}
|
|
6066
|
+
recordFinding(ctx, project.id, {
|
|
6067
|
+
key: input.key,
|
|
6068
|
+
value: input.value,
|
|
6069
|
+
source,
|
|
6070
|
+
...input.evidence ? { evidence: input.evidence } : {}
|
|
6071
|
+
});
|
|
6072
|
+
emitProject(ctx, project.id, {
|
|
6073
|
+
type: "prep.finding_recorded",
|
|
6074
|
+
message: `preparation session established ${input.key} (${source})`,
|
|
6075
|
+
data: { key: input.key, source, sessionId: session.id }
|
|
6076
|
+
});
|
|
6077
|
+
return { ok: true, key: input.key, source };
|
|
6078
|
+
}
|
|
4935
6079
|
function ok(result) {
|
|
4936
6080
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
4937
6081
|
}
|
|
@@ -4953,6 +6097,21 @@ async function resolveCtxSession(extra) {
|
|
|
4953
6097
|
}
|
|
4954
6098
|
function buildMcpServer() {
|
|
4955
6099
|
const server = new McpServer({ name: "runcastle", version: "0.1.0" });
|
|
6100
|
+
server.registerTool("record_finding", {
|
|
6101
|
+
title: "Record a preparation finding",
|
|
6102
|
+
description: "Store one established project setting from a preparation conversation. " + "Set userSupplied: true ONLY when the human gave you this value or confirmed it verbatim \u2014 " + "that marks it as theirs and stops any future automatic run from overwriting it. " + "Anything you worked out yourself must leave it false, even if they were watching. " + "Always include evidence: what you ran, or what the human told you.",
|
|
6103
|
+
inputSchema: {
|
|
6104
|
+
key: PreparedKey,
|
|
6105
|
+
value: z4.string(),
|
|
6106
|
+
evidence: z4.string().optional(),
|
|
6107
|
+
userSupplied: z4.boolean().optional()
|
|
6108
|
+
}
|
|
6109
|
+
}, async (args, extra) => {
|
|
6110
|
+
const rs = await resolveCtxSession(extra);
|
|
6111
|
+
if (!rs)
|
|
6112
|
+
return noSession();
|
|
6113
|
+
return ok(toolRecordFinding(rs.ctx, rs.session, args));
|
|
6114
|
+
});
|
|
4956
6115
|
server.registerTool("get_feature_context", {
|
|
4957
6116
|
title: "Get feature context",
|
|
4958
6117
|
description: "Full context for the current feature: the feature row, its phase, all docs/features/<slug>/*.md contents, and its tickets.",
|
|
@@ -4966,7 +6125,7 @@ function buildMcpServer() {
|
|
|
4966
6125
|
server.registerTool("emit_tickets", {
|
|
4967
6126
|
title: "Emit tickets",
|
|
4968
6127
|
description: "Store the ideation session's ticket batch. Each ticket: title, goal, context, acceptanceCriteria[], seams[], blockedBy[] (1-based positions within THIS batch).",
|
|
4969
|
-
inputSchema: { tickets:
|
|
6128
|
+
inputSchema: { tickets: z4.array(TicketInput) }
|
|
4970
6129
|
}, async (args, extra) => {
|
|
4971
6130
|
const rs = await resolveCtxSession(extra);
|
|
4972
6131
|
if (!rs)
|
|
@@ -4979,12 +6138,12 @@ function buildMcpServer() {
|
|
|
4979
6138
|
title: "Update ticket",
|
|
4980
6139
|
description: "Rewrite a stored ticket's content \u2014 title, goal, context, acceptanceCriteria, seams (any subset). Only pending or failed tickets can be edited; done/cancelled tickets are history and burning tickets are already running. Use during a revisit when a decision change makes a ticket stale. Get ids from get_feature_context.",
|
|
4981
6140
|
inputSchema: {
|
|
4982
|
-
id:
|
|
4983
|
-
title:
|
|
4984
|
-
goal:
|
|
4985
|
-
context:
|
|
4986
|
-
acceptanceCriteria:
|
|
4987
|
-
seams:
|
|
6141
|
+
id: z4.string(),
|
|
6142
|
+
title: z4.string().optional(),
|
|
6143
|
+
goal: z4.string().optional(),
|
|
6144
|
+
context: z4.string().optional(),
|
|
6145
|
+
acceptanceCriteria: z4.array(z4.string()).optional(),
|
|
6146
|
+
seams: z4.array(z4.string()).optional()
|
|
4988
6147
|
}
|
|
4989
6148
|
}, async (args, extra) => {
|
|
4990
6149
|
const rs = await resolveCtxSession(extra);
|
|
@@ -4997,7 +6156,7 @@ function buildMcpServer() {
|
|
|
4997
6156
|
server.registerTool("cancel_ticket", {
|
|
4998
6157
|
title: "Cancel ticket",
|
|
4999
6158
|
description: "Cancel a ticket that is no longer needed (terminal state; only pending or failed tickets). The burner skips cancelled tickets, and tickets blocked by a cancelled one still burn \u2014 cancellation counts as satisfied. Pass a short reason so the timeline explains why.",
|
|
5000
|
-
inputSchema: { id:
|
|
6159
|
+
inputSchema: { id: z4.string(), reason: z4.string().optional() }
|
|
5001
6160
|
}, async (args, extra) => {
|
|
5002
6161
|
const rs = await resolveCtxSession(extra);
|
|
5003
6162
|
if (!rs)
|
|
@@ -5009,7 +6168,7 @@ function buildMcpServer() {
|
|
|
5009
6168
|
server.registerTool("escalate_to_map", {
|
|
5010
6169
|
title: "Escalate to map",
|
|
5011
6170
|
description: "Escalate this grilling session into a map when the feature outgrows one context window. Flips the feature to mapped and scaffolds docs/features/<slug>/map.md, seeding Destination + Notes from your arguments (Not-yet-specified and Out-of-scope start empty). Idempotent: calling it on an already-mapped feature warns and changes nothing.",
|
|
5012
|
-
inputSchema: { destination:
|
|
6171
|
+
inputSchema: { destination: z4.string(), notes: z4.string().optional() }
|
|
5013
6172
|
}, async (args, extra) => {
|
|
5014
6173
|
const rs = await resolveCtxSession(extra);
|
|
5015
6174
|
if (!rs)
|
|
@@ -5023,7 +6182,7 @@ function buildMcpServer() {
|
|
|
5023
6182
|
server.registerTool("emit_waypoints", {
|
|
5024
6183
|
title: "Emit waypoints",
|
|
5025
6184
|
description: "Batch-create waypoints on the map (the feature must already be mapped \u2014 call escalate_to_map first). Each waypoint: title, type (grilling|research|prototype|task), question, blockedBy[] (1-based positions within THIS batch, and/or ids of already-stored waypoints). Available from any session once mapped \u2014 any session may branch the map.",
|
|
5026
|
-
inputSchema: { waypoints:
|
|
6185
|
+
inputSchema: { waypoints: z4.array(WaypointInput) }
|
|
5027
6186
|
}, async (args, extra) => {
|
|
5028
6187
|
const rs = await resolveCtxSession(extra);
|
|
5029
6188
|
if (!rs)
|
|
@@ -5033,7 +6192,7 @@ function buildMcpServer() {
|
|
|
5033
6192
|
server.registerTool("resolve_waypoint", {
|
|
5034
6193
|
title: "Resolve waypoint",
|
|
5035
6194
|
description: "End the current waypoint: `resolved` (its question is answered) or `dropped` (no longer needed). Write the decision prose to decisions.md (or the gist to map.md Out-of-scope for a drop) FIRST \u2014 this tool flips machinery only (marks the waypoint terminal, frees any dependents on the frontier). `summary` is the one-line gist shown in the UI. Call this exactly once, as the last thing you do.",
|
|
5036
|
-
inputSchema: { id:
|
|
6195
|
+
inputSchema: { id: z4.string(), disposition: WaypointDisposition, summary: z4.string() }
|
|
5037
6196
|
}, async (args, extra) => {
|
|
5038
6197
|
const rs = await resolveCtxSession(extra);
|
|
5039
6198
|
if (!rs)
|
|
@@ -5045,7 +6204,7 @@ function buildMcpServer() {
|
|
|
5045
6204
|
server.registerTool("record_event", {
|
|
5046
6205
|
title: "Record event",
|
|
5047
6206
|
description: "Add a note to the feature timeline (decisions recorded, spec saved, milestones).",
|
|
5048
|
-
inputSchema: { type:
|
|
6207
|
+
inputSchema: { type: z4.string(), message: z4.string() }
|
|
5049
6208
|
}, async (args, extra) => {
|
|
5050
6209
|
const rs = await resolveCtxSession(extra);
|
|
5051
6210
|
if (!rs)
|
|
@@ -5075,6 +6234,7 @@ var init_server = __esm(() => {
|
|
|
5075
6234
|
init_sessions();
|
|
5076
6235
|
init_features();
|
|
5077
6236
|
init_events();
|
|
6237
|
+
init_findings();
|
|
5078
6238
|
init_git();
|
|
5079
6239
|
init_knowledge();
|
|
5080
6240
|
init_repo();
|
|
@@ -5168,12 +6328,16 @@ import { Hono as Hono2 } from "hono";
|
|
|
5168
6328
|
function handleSessionStart(ctx, sessionId, feature, payload) {
|
|
5169
6329
|
const ccSessionId = typeof payload?.session_id === "string" ? payload.session_id : undefined;
|
|
5170
6330
|
const transcriptPath = typeof payload?.transcript_path === "string" ? payload.transcript_path : undefined;
|
|
6331
|
+
const source = typeof payload?.source === "string" ? payload.source : undefined;
|
|
6332
|
+
const wasLive = getSessionRow(ctx, sessionId)?.status === "live";
|
|
5171
6333
|
markSessionLive(ctx, sessionId, { ccSessionId, transcriptPath });
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
6334
|
+
if (!wasLive) {
|
|
6335
|
+
emit(ctx, feature.id, {
|
|
6336
|
+
type: "session.started",
|
|
6337
|
+
message: source === "resume" ? "session live (conversation resumed)" : "session live",
|
|
6338
|
+
data: { sessionId, ccSessionId, transcriptPath, source: source ?? null }
|
|
6339
|
+
});
|
|
6340
|
+
}
|
|
5177
6341
|
return {
|
|
5178
6342
|
hookSpecificOutput: {
|
|
5179
6343
|
hookEventName: "SessionStart",
|
|
@@ -5181,7 +6345,60 @@ function handleSessionStart(ctx, sessionId, feature, payload) {
|
|
|
5181
6345
|
}
|
|
5182
6346
|
};
|
|
5183
6347
|
}
|
|
5184
|
-
function
|
|
6348
|
+
function handlePrepareSessionStart(ctx, session, payload) {
|
|
6349
|
+
const ccSessionId = typeof payload?.session_id === "string" ? payload.session_id : undefined;
|
|
6350
|
+
const transcriptPath = typeof payload?.transcript_path === "string" ? payload.transcript_path : undefined;
|
|
6351
|
+
const source = typeof payload?.source === "string" ? payload.source : undefined;
|
|
6352
|
+
const wasLive = getSessionRow(ctx, session.id)?.status === "live";
|
|
6353
|
+
markSessionLive(ctx, session.id, { ccSessionId, transcriptPath });
|
|
6354
|
+
if (!wasLive) {
|
|
6355
|
+
emitForSession(ctx, session, {
|
|
6356
|
+
type: "session.started",
|
|
6357
|
+
message: source === "resume" ? "preparation session live (resumed)" : "preparation session live",
|
|
6358
|
+
data: { sessionId: session.id, ccSessionId, transcriptPath, source: source ?? null }
|
|
6359
|
+
});
|
|
6360
|
+
}
|
|
6361
|
+
return {
|
|
6362
|
+
hookSpecificOutput: {
|
|
6363
|
+
hookEventName: "SessionStart",
|
|
6364
|
+
additionalContext: prepareStartContext(ctx, session)
|
|
6365
|
+
}
|
|
6366
|
+
};
|
|
6367
|
+
}
|
|
6368
|
+
function handlePrepareUserPrompt(ctx, session, payload) {
|
|
6369
|
+
const prompt = typeof payload?.prompt === "string" ? payload.prompt : undefined;
|
|
6370
|
+
noteKickoffPrompt(ctx, session.id, prompt);
|
|
6371
|
+
return {
|
|
6372
|
+
hookSpecificOutput: {
|
|
6373
|
+
hookEventName: "UserPromptSubmit",
|
|
6374
|
+
additionalContext: `[runcastle] preparation session (project-scoped, no feature)`
|
|
6375
|
+
}
|
|
6376
|
+
};
|
|
6377
|
+
}
|
|
6378
|
+
function handlePrepareSessionEnd(ctx, session) {
|
|
6379
|
+
markSessionEnded(ctx, session.id);
|
|
6380
|
+
emitForSession(ctx, session, {
|
|
6381
|
+
type: "session.ended",
|
|
6382
|
+
message: "preparation session ended",
|
|
6383
|
+
data: { sessionId: session.id }
|
|
6384
|
+
});
|
|
6385
|
+
return {};
|
|
6386
|
+
}
|
|
6387
|
+
function prepareStartContext(ctx, session) {
|
|
6388
|
+
const project = session.projectId ? getProjectById(ctx, session.projectId) : null;
|
|
6389
|
+
if (!project)
|
|
6390
|
+
return "[runcastle] preparation session \u2014 project not found";
|
|
6391
|
+
const remaining = keysToPrepare(ctx, project);
|
|
6392
|
+
return [
|
|
6393
|
+
`[runcastle] preparation session for ${project.name} (${project.repoPath}).`,
|
|
6394
|
+
remaining.length > 0 ? `Still unestablished: ${remaining.join(", ")}.` : "Every prepared field currently has a value.",
|
|
6395
|
+
"Record what you establish with the `record_finding` MCP tool \u2014 a value the user",
|
|
6396
|
+
"gives you or confirms verbatim is theirs (userSupplied: true), anything you measured is not."
|
|
6397
|
+
].join(" ");
|
|
6398
|
+
}
|
|
6399
|
+
function handleUserPrompt(ctx, sessionId, feature, payload) {
|
|
6400
|
+
const prompt = typeof payload?.prompt === "string" ? payload.prompt : undefined;
|
|
6401
|
+
noteKickoffPrompt(ctx, sessionId, prompt);
|
|
5185
6402
|
const tickets2 = listByFeature2(ctx, feature.id).length;
|
|
5186
6403
|
return {
|
|
5187
6404
|
hookSpecificOutput: {
|
|
@@ -5220,6 +6437,7 @@ var hooks, hooks_default;
|
|
|
5220
6437
|
var init_hooks = __esm(() => {
|
|
5221
6438
|
init_sessions();
|
|
5222
6439
|
init_events();
|
|
6440
|
+
init_prep();
|
|
5223
6441
|
init_repo();
|
|
5224
6442
|
init_tickets();
|
|
5225
6443
|
init_waypoints();
|
|
@@ -5241,6 +6459,18 @@ var init_hooks = __esm(() => {
|
|
|
5241
6459
|
const session = getSessionRow(ctx, sessionId);
|
|
5242
6460
|
if (!session)
|
|
5243
6461
|
return c.json({});
|
|
6462
|
+
if (!session.featureId) {
|
|
6463
|
+
switch (event) {
|
|
6464
|
+
case "session-start":
|
|
6465
|
+
return c.json(handlePrepareSessionStart(ctx, session, body.payload));
|
|
6466
|
+
case "user-prompt":
|
|
6467
|
+
return c.json(handlePrepareUserPrompt(ctx, session, body.payload));
|
|
6468
|
+
case "session-end":
|
|
6469
|
+
return c.json(handlePrepareSessionEnd(ctx, session));
|
|
6470
|
+
default:
|
|
6471
|
+
return c.json({});
|
|
6472
|
+
}
|
|
6473
|
+
}
|
|
5244
6474
|
const feature = tryGetFeature(ctx, session.featureId);
|
|
5245
6475
|
if (!feature)
|
|
5246
6476
|
return c.json({});
|
|
@@ -5248,7 +6478,7 @@ var init_hooks = __esm(() => {
|
|
|
5248
6478
|
case "session-start":
|
|
5249
6479
|
return c.json(handleSessionStart(ctx, sessionId, feature, body.payload));
|
|
5250
6480
|
case "user-prompt":
|
|
5251
|
-
return c.json(handleUserPrompt(ctx, feature));
|
|
6481
|
+
return c.json(handleUserPrompt(ctx, sessionId, feature, body.payload));
|
|
5252
6482
|
case "session-end":
|
|
5253
6483
|
return c.json(handleSessionEnd(ctx, sessionId, feature));
|
|
5254
6484
|
default:
|
|
@@ -5313,9 +6543,9 @@ var init_stream = __esm(() => {
|
|
|
5313
6543
|
});
|
|
5314
6544
|
|
|
5315
6545
|
// src/routes/web.ts
|
|
5316
|
-
import { existsSync as
|
|
6546
|
+
import { existsSync as existsSync13, statSync } from "fs";
|
|
5317
6547
|
import { readFile } from "fs/promises";
|
|
5318
|
-
import { extname, join as
|
|
6548
|
+
import { extname, join as join12, normalize, resolve as resolve7, sep as sep2 } from "path";
|
|
5319
6549
|
function contentType(path) {
|
|
5320
6550
|
return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
5321
6551
|
}
|
|
@@ -5333,7 +6563,7 @@ function safeJoin(root, pathname) {
|
|
|
5333
6563
|
} catch {
|
|
5334
6564
|
return null;
|
|
5335
6565
|
}
|
|
5336
|
-
const joined = normalize(
|
|
6566
|
+
const joined = normalize(join12(root, decoded));
|
|
5337
6567
|
if (joined !== root && !joined.startsWith(root + sep2))
|
|
5338
6568
|
return null;
|
|
5339
6569
|
return joined;
|
|
@@ -5341,12 +6571,12 @@ function safeJoin(root, pathname) {
|
|
|
5341
6571
|
function resolveWebDist() {
|
|
5342
6572
|
const override = process.env.RUNCASTLE_WEB_DIST;
|
|
5343
6573
|
if (override)
|
|
5344
|
-
return
|
|
5345
|
-
return
|
|
6574
|
+
return resolve7(override);
|
|
6575
|
+
return resolve7(import.meta.dirname, "..", "..", "..", "..", "apps", "web", "dist");
|
|
5346
6576
|
}
|
|
5347
6577
|
function mountWebApp(app, distDir) {
|
|
5348
|
-
const root =
|
|
5349
|
-
const indexHtml =
|
|
6578
|
+
const root = resolve7(distDir);
|
|
6579
|
+
const indexHtml = join12(root, "index.html");
|
|
5350
6580
|
app.get("*", async (c, next) => {
|
|
5351
6581
|
const { pathname } = new URL(c.req.url);
|
|
5352
6582
|
if (/^\/(api|mcp|ws|health)(\/|$)/.test(pathname))
|
|
@@ -5366,7 +6596,7 @@ function mountWebApp(app, distDir) {
|
|
|
5366
6596
|
}
|
|
5367
6597
|
function mountWebAppIfBuilt(app) {
|
|
5368
6598
|
const dist = resolveWebDist();
|
|
5369
|
-
if (
|
|
6599
|
+
if (existsSync13(join12(dist, "index.html")))
|
|
5370
6600
|
mountWebApp(app, dist);
|
|
5371
6601
|
}
|
|
5372
6602
|
var MIME;
|
|
@@ -5413,14 +6643,14 @@ var init_context = __esm(() => {
|
|
|
5413
6643
|
});
|
|
5414
6644
|
|
|
5415
6645
|
// src/trpc/routers/docs.ts
|
|
5416
|
-
import * as
|
|
6646
|
+
import * as z5 from "zod";
|
|
5417
6647
|
var docsRouter;
|
|
5418
6648
|
var init_docs = __esm(() => {
|
|
5419
6649
|
init_knowledge();
|
|
5420
6650
|
init_repo();
|
|
5421
6651
|
init_context();
|
|
5422
6652
|
docsRouter = router({
|
|
5423
|
-
read: publicProcedure.input(
|
|
6653
|
+
read: publicProcedure.input(z5.object({ featureId: z5.string(), relPath: z5.string() })).query(({ ctx, input }) => {
|
|
5424
6654
|
const feature = getFeatureRow(ctx, input.featureId);
|
|
5425
6655
|
return readDoc(ctx, feature, input.relPath);
|
|
5426
6656
|
})
|
|
@@ -5428,23 +6658,23 @@ var init_docs = __esm(() => {
|
|
|
5428
6658
|
});
|
|
5429
6659
|
|
|
5430
6660
|
// src/trpc/routers/events.ts
|
|
5431
|
-
import * as
|
|
6661
|
+
import * as z6 from "zod";
|
|
5432
6662
|
var eventsRouter;
|
|
5433
6663
|
var init_events2 = __esm(() => {
|
|
5434
6664
|
init_events();
|
|
5435
6665
|
init_context();
|
|
5436
6666
|
eventsRouter = router({
|
|
5437
|
-
list: publicProcedure.input(
|
|
5438
|
-
listByProject: publicProcedure.input(
|
|
6667
|
+
list: publicProcedure.input(z6.object({ featureId: z6.string(), afterId: z6.number().optional() })).query(({ ctx, input }) => listAfter(ctx, input.featureId, input.afterId)),
|
|
6668
|
+
listByProject: publicProcedure.input(z6.object({ projectId: z6.string(), afterId: z6.number().optional() })).query(({ ctx, input }) => listByProject(ctx, input.projectId, input.afterId))
|
|
5439
6669
|
});
|
|
5440
6670
|
});
|
|
5441
6671
|
|
|
5442
6672
|
// src/launcher/artifacts.ts
|
|
5443
|
-
import { mkdirSync as
|
|
5444
|
-
import { fileURLToPath as
|
|
5445
|
-
import { join as
|
|
6673
|
+
import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync2 } from "fs";
|
|
6674
|
+
import { fileURLToPath as fileURLToPath5 } from "url";
|
|
6675
|
+
import { join as join13 } from "path";
|
|
5446
6676
|
function hookClientPath() {
|
|
5447
|
-
return resolveAsset(ASSET_ENV.hookClient,
|
|
6677
|
+
return resolveAsset(ASSET_ENV.hookClient, fileURLToPath5(new URL("./hook-client.ts", import.meta.url)));
|
|
5448
6678
|
}
|
|
5449
6679
|
function serverUrlFor(config2) {
|
|
5450
6680
|
return `http://localhost:${config2.serverPort}`;
|
|
@@ -5636,6 +6866,69 @@ function renderRevisitPrompt(feature) {
|
|
|
5636
6866
|
].join(`
|
|
5637
6867
|
`);
|
|
5638
6868
|
}
|
|
6869
|
+
function renderPreparePrompt(brief) {
|
|
6870
|
+
const { project, remainingKeys, established, notes } = brief;
|
|
6871
|
+
return [
|
|
6872
|
+
`# runcastle \u2014 preparing ${project.name}`,
|
|
6873
|
+
"",
|
|
6874
|
+
"This is a **preparation session**: a project-scoped conversation whose job is to",
|
|
6875
|
+
"establish the settings every later agent depends on, and to record them with evidence.",
|
|
6876
|
+
"There is no feature here and no pipeline to advance.",
|
|
6877
|
+
"",
|
|
6878
|
+
"## Where this runs \u2014 and why that matters",
|
|
6879
|
+
`You are on the developer's own machine, in \`${project.repoPath}\`, NOT in a sandbox.`,
|
|
6880
|
+
"A headless preparation run measures the repo in a throwaway container, which means it",
|
|
6881
|
+
"can never verify anything about *this* machine: the dev server, the local database,",
|
|
6882
|
+
"docker, credentials. Those keys \u2014 `devCommand`, `driveSetupCommand`, `driveStopCommand`,",
|
|
6883
|
+
"`driveEnv`, `dbResetCommand` \u2014 are exactly what it has to leave as proposals.",
|
|
6884
|
+
"You can actually run them. That is the point of this session.",
|
|
6885
|
+
"",
|
|
6886
|
+
"The same access is why you must **ask before you act**. Anything that starts or stops a",
|
|
6887
|
+
"service, creates or migrates a database, or writes outside the repo needs the human to",
|
|
6888
|
+
"agree first \u2014 say what you are about to run and why, then wait.",
|
|
6889
|
+
"",
|
|
6890
|
+
...established.length > 0 ? [
|
|
6891
|
+
"## Already established \u2014 do not re-derive",
|
|
6892
|
+
"These are recorded findings. Treat them as true unless the human says otherwise;",
|
|
6893
|
+
"replacing a measured value with a fresh guess makes preparation worse, not better.",
|
|
6894
|
+
"",
|
|
6895
|
+
...established.map((f) => `- \`${f.key}\` (${f.source})${f.evidence ? ` \u2014 ${f.evidence}` : ""}`),
|
|
6896
|
+
""
|
|
6897
|
+
] : [],
|
|
6898
|
+
...notes ? [
|
|
6899
|
+
"## What the last run could not settle",
|
|
6900
|
+
"Its own words. Where it says it declined to guess, it was right to \u2014 that is the",
|
|
6901
|
+
"gap you are here to close, and closing it usually means asking one direct question.",
|
|
6902
|
+
"",
|
|
6903
|
+
notes,
|
|
6904
|
+
""
|
|
6905
|
+
] : [],
|
|
6906
|
+
"## Still open",
|
|
6907
|
+
remainingKeys.length > 0 ? remainingKeys.map((k) => `- \`${k}\``).join(`
|
|
6908
|
+
`) : "_Nothing is unset. Confirm the existing values still hold, then say so and stop._",
|
|
6909
|
+
"",
|
|
6910
|
+
"## Recording what you establish",
|
|
6911
|
+
"- `record_finding({ key, value, evidence, userSupplied })` \u2014 one call per key.",
|
|
6912
|
+
"- `userSupplied: true` means the human GAVE you this value or confirmed it verbatim.",
|
|
6913
|
+
" That marks it as theirs and permanently stops automatic runs from overwriting it.",
|
|
6914
|
+
"- Leave it false for anything you worked out yourself, even with them watching \u2014",
|
|
6915
|
+
" that stays improvable by a later run. Getting this backwards silently retires a",
|
|
6916
|
+
" field from preparation forever, and the only way back is the human clearing it.",
|
|
6917
|
+
"- `evidence` is not optional in spirit: record what you ran and what it printed, or",
|
|
6918
|
+
" what the human told you. A value with no account of itself is a guess with a source field.",
|
|
6919
|
+
"",
|
|
6920
|
+
"## Secrets",
|
|
6921
|
+
"This is a development environment and the human has agreed to supply real connection",
|
|
6922
|
+
"strings and credentials here. Store them as given. Do not paste a secret into a timeline",
|
|
6923
|
+
"note or a commit message \u2014 `record_finding` is the only place a value belongs.",
|
|
6924
|
+
"",
|
|
6925
|
+
"## Your task",
|
|
6926
|
+
"Open by telling the human which fields are still open and what you need from them for",
|
|
6927
|
+
"each. Work them one at a time: propose, ask, run it if they agree, then record it.",
|
|
6928
|
+
""
|
|
6929
|
+
].join(`
|
|
6930
|
+
`);
|
|
6931
|
+
}
|
|
5639
6932
|
function renderSettings(hookClient) {
|
|
5640
6933
|
const cmd = (event) => ({
|
|
5641
6934
|
type: "command",
|
|
@@ -5645,7 +6938,10 @@ function renderSettings(hookClient) {
|
|
|
5645
6938
|
return {
|
|
5646
6939
|
permissions: { allow: [...RUNCASTLE_MCP_ALLOW_RULES, ...SESSION_BASH_ALLOW_RULES] },
|
|
5647
6940
|
hooks: {
|
|
5648
|
-
SessionStart:
|
|
6941
|
+
SessionStart: SESSION_START_SOURCES.map((source) => ({
|
|
6942
|
+
matcher: source,
|
|
6943
|
+
hooks: [cmd("session-start")]
|
|
6944
|
+
})),
|
|
5649
6945
|
UserPromptSubmit: [{ hooks: [cmd("user-prompt")] }],
|
|
5650
6946
|
SessionEnd: [{ hooks: [cmd("session-end")] }]
|
|
5651
6947
|
}
|
|
@@ -5663,18 +6959,21 @@ function renderMcpConfig(session, config2) {
|
|
|
5663
6959
|
};
|
|
5664
6960
|
}
|
|
5665
6961
|
async function writeSessionArtifacts(input) {
|
|
5666
|
-
const { session, feature, config: config2, waypoint } = input;
|
|
6962
|
+
const { session, feature, config: config2, waypoint, prepare } = input;
|
|
5667
6963
|
const dir = sessionDir(session.id);
|
|
5668
|
-
|
|
5669
|
-
const systemPromptPath =
|
|
5670
|
-
const settingsPath =
|
|
5671
|
-
const mcpConfigPath =
|
|
5672
|
-
|
|
6964
|
+
mkdirSync7(dir, { recursive: true });
|
|
6965
|
+
const systemPromptPath = join13(dir, "system-prompt.md");
|
|
6966
|
+
const settingsPath = join13(dir, "settings.json");
|
|
6967
|
+
const mcpConfigPath = join13(dir, "mcp.json");
|
|
6968
|
+
const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint) : prepare ? renderPreparePrompt(prepare) : (() => {
|
|
6969
|
+
throw new Error(`session ${session.id} has neither a feature nor a preparation brief`);
|
|
6970
|
+
})();
|
|
6971
|
+
writeFileSync2(systemPromptPath, systemPrompt, "utf8");
|
|
5673
6972
|
writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath()), null, 2), "utf8");
|
|
5674
6973
|
writeFileSync2(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
|
|
5675
6974
|
return { systemPromptPath, settingsPath, mcpConfigPath };
|
|
5676
6975
|
}
|
|
5677
|
-
var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_ALLOW_RULES;
|
|
6976
|
+
var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_ALLOW_RULES, SESSION_START_SOURCES;
|
|
5678
6977
|
var init_artifacts = __esm(() => {
|
|
5679
6978
|
init_paths();
|
|
5680
6979
|
init_asset_paths();
|
|
@@ -5687,7 +6986,8 @@ var init_artifacts = __esm(() => {
|
|
|
5687
6986
|
"mcp__runcastle__complete_phase",
|
|
5688
6987
|
"mcp__runcastle__escalate_to_map",
|
|
5689
6988
|
"mcp__runcastle__emit_waypoints",
|
|
5690
|
-
"mcp__runcastle__resolve_waypoint"
|
|
6989
|
+
"mcp__runcastle__resolve_waypoint",
|
|
6990
|
+
"mcp__runcastle__record_finding"
|
|
5691
6991
|
];
|
|
5692
6992
|
SESSION_BASH_ALLOW_RULES = [
|
|
5693
6993
|
"Bash(git status:*)",
|
|
@@ -5699,13 +6999,14 @@ var init_artifacts = __esm(() => {
|
|
|
5699
6999
|
"Bash(git branch:*)",
|
|
5700
7000
|
"Bash(git show:*)"
|
|
5701
7001
|
];
|
|
7002
|
+
SESSION_START_SOURCES = ["startup", "resume", "clear", "compact", "fork"];
|
|
5702
7003
|
});
|
|
5703
7004
|
|
|
5704
7005
|
// src/launcher/launcher.ts
|
|
5705
|
-
import { existsSync as
|
|
5706
|
-
import { dirname as
|
|
5707
|
-
import { fileURLToPath as
|
|
5708
|
-
import { and as
|
|
7006
|
+
import { existsSync as existsSync14 } from "fs";
|
|
7007
|
+
import { dirname as dirname6, join as join14, resolve as resolve8 } from "path";
|
|
7008
|
+
import { fileURLToPath as fileURLToPath6 } from "url";
|
|
7009
|
+
import { and as and8, eq as eq10 } from "drizzle-orm";
|
|
5709
7010
|
function ptyExitMessage(exitCode) {
|
|
5710
7011
|
const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
|
|
5711
7012
|
return `terminal exited (code ${label})`;
|
|
@@ -5719,7 +7020,7 @@ function buildClaudeArgs(input) {
|
|
|
5719
7020
|
input.settingsPath,
|
|
5720
7021
|
"--mcp-config",
|
|
5721
7022
|
input.mcpConfigPath,
|
|
5722
|
-
"--strict-mcp-config",
|
|
7023
|
+
...input.strictMcp ? ["--strict-mcp-config"] : [],
|
|
5723
7024
|
"--plugin-dir",
|
|
5724
7025
|
input.pluginDir,
|
|
5725
7026
|
"--append-system-prompt-file",
|
|
@@ -5736,23 +7037,23 @@ function resolveClaudeExecutable() {
|
|
|
5736
7037
|
function claudeSpawnTarget(claudeArgs) {
|
|
5737
7038
|
return spawnTargetFor(resolveClaudeExecutable(), claudeArgs);
|
|
5738
7039
|
}
|
|
5739
|
-
function resolvePluginDir(fromDir =
|
|
5740
|
-
const rel =
|
|
7040
|
+
function resolvePluginDir(fromDir = dirname6(fileURLToPath6(import.meta.url))) {
|
|
7041
|
+
const rel = join14("packages", "skills", "packs", "runcastle");
|
|
5741
7042
|
const override = process.env[SKILLS_DIR_ENV];
|
|
5742
7043
|
if (override) {
|
|
5743
|
-
const dir2 =
|
|
5744
|
-
if (
|
|
7044
|
+
const dir2 = join14(resolve8(override), "packs", "runcastle");
|
|
7045
|
+
if (existsSync14(dir2))
|
|
5745
7046
|
return dir2;
|
|
5746
7047
|
throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
|
|
5747
7048
|
}
|
|
5748
7049
|
const searched = [];
|
|
5749
7050
|
let dir = fromDir;
|
|
5750
7051
|
for (let i = 0;i < 8; i += 1) {
|
|
5751
|
-
const candidate =
|
|
7052
|
+
const candidate = join14(dir, rel);
|
|
5752
7053
|
searched.push(candidate);
|
|
5753
|
-
if (
|
|
7054
|
+
if (existsSync14(candidate))
|
|
5754
7055
|
return candidate;
|
|
5755
|
-
const parent =
|
|
7056
|
+
const parent = dirname6(dir);
|
|
5756
7057
|
if (parent === dir)
|
|
5757
7058
|
break;
|
|
5758
7059
|
dir = parent;
|
|
@@ -5762,7 +7063,7 @@ function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
|
|
|
5762
7063
|
`)}`);
|
|
5763
7064
|
}
|
|
5764
7065
|
function activeRunFor(ctx, featureId) {
|
|
5765
|
-
const row = ctx.db.select().from(runs).where(
|
|
7066
|
+
const row = ctx.db.select().from(runs).where(and8(eq10(runs.featureId, featureId), eq10(runs.status, "running"))).limit(1).get();
|
|
5766
7067
|
return row ? rowToRun(row) : null;
|
|
5767
7068
|
}
|
|
5768
7069
|
function assertSpawnable(ctx, feature, excludeSessionId) {
|
|
@@ -5775,6 +7076,29 @@ function assertSpawnable(ctx, feature, excludeSessionId) {
|
|
|
5775
7076
|
throw new GateError(`a ${running.workflow} run is in progress on ${feature.slug} \u2014 it holds the feature branch; terminals are available when it finishes`);
|
|
5776
7077
|
}
|
|
5777
7078
|
}
|
|
7079
|
+
function sessionFinished(ctx, feature, session) {
|
|
7080
|
+
if (session.kind !== "waypoint")
|
|
7081
|
+
return feature.mapped;
|
|
7082
|
+
const own = listByFeature(ctx, feature.id).find((w) => w.lastSessionId === session.id);
|
|
7083
|
+
return !!own && (own.status === "resolved" || own.status === "dropped");
|
|
7084
|
+
}
|
|
7085
|
+
function sweepActiveSessions(ctx, feature, endLive) {
|
|
7086
|
+
for (const session of activeSessionsForFeature(ctx, feature.id)) {
|
|
7087
|
+
const finished = sessionFinished(ctx, feature, session);
|
|
7088
|
+
if (!finished && !endLive)
|
|
7089
|
+
continue;
|
|
7090
|
+
endSession(ctx, session.id);
|
|
7091
|
+
emit(ctx, feature.id, {
|
|
7092
|
+
type: "session.auto_ended",
|
|
7093
|
+
message: finished ? `ended the finished ${session.kind} session to work the next waypoint` : `abandoned the in-flight ${session.kind} session to work the next waypoint`,
|
|
7094
|
+
data: {
|
|
7095
|
+
sessionId: session.id,
|
|
7096
|
+
kind: session.kind,
|
|
7097
|
+
reason: finished ? "finished" : "abandoned"
|
|
7098
|
+
}
|
|
7099
|
+
});
|
|
7100
|
+
}
|
|
7101
|
+
}
|
|
5778
7102
|
async function ensureWorktree(ctx, project, feature) {
|
|
5779
7103
|
try {
|
|
5780
7104
|
return await ensureTalkWorktree(project, feature);
|
|
@@ -5882,7 +7206,8 @@ async function launchSession(ctx, input, opts = {}) {
|
|
|
5882
7206
|
mcpConfigPath: artifacts.mcpConfigPath,
|
|
5883
7207
|
systemPromptPath: artifacts.systemPromptPath,
|
|
5884
7208
|
model: resolveModel(input.kind, ctx.config, project),
|
|
5885
|
-
resumeSessionId
|
|
7209
|
+
resumeSessionId,
|
|
7210
|
+
strictMcp: ctx.config.sessionMcp === "runcastleOnly"
|
|
5886
7211
|
};
|
|
5887
7212
|
if (opts.spawn === false) {
|
|
5888
7213
|
emit(ctx, feature.id, {
|
|
@@ -5902,6 +7227,75 @@ async function launchSession(ctx, input, opts = {}) {
|
|
|
5902
7227
|
});
|
|
5903
7228
|
return { sessionId: session.id };
|
|
5904
7229
|
}
|
|
7230
|
+
async function launchPrepareSession(ctx, input, opts = {}) {
|
|
7231
|
+
const project = requireProjectById(ctx, input.projectId);
|
|
7232
|
+
const session = createSessionRow(ctx, {
|
|
7233
|
+
projectId: project.id,
|
|
7234
|
+
kind: "prepare",
|
|
7235
|
+
worktreePath: project.repoPath
|
|
7236
|
+
});
|
|
7237
|
+
const resumeSessionId = mostRecentResumableProjectSession(ctx, project.id, "prepare")?.ccSessionId;
|
|
7238
|
+
emitProject(ctx, project.id, {
|
|
7239
|
+
type: "session.launching",
|
|
7240
|
+
message: "launching preparation session",
|
|
7241
|
+
data: { sessionId: session.id, kind: "prepare", worktreePath: project.repoPath }
|
|
7242
|
+
});
|
|
7243
|
+
if (resumeSessionId) {
|
|
7244
|
+
emitProject(ctx, project.id, {
|
|
7245
|
+
type: "session.resumed",
|
|
7246
|
+
message: "resuming the previous preparation conversation",
|
|
7247
|
+
data: { sessionId: session.id, resumeSessionId }
|
|
7248
|
+
});
|
|
7249
|
+
}
|
|
7250
|
+
const artifacts = await writeSessionArtifacts({
|
|
7251
|
+
session,
|
|
7252
|
+
project,
|
|
7253
|
+
config: ctx.config,
|
|
7254
|
+
prepare: await buildPrepareBrief(ctx, project)
|
|
7255
|
+
});
|
|
7256
|
+
const serverUrl = serverUrlFor(ctx.config);
|
|
7257
|
+
const buildInput = {
|
|
7258
|
+
sessionId: session.id,
|
|
7259
|
+
serverUrl,
|
|
7260
|
+
featureTitle: `preparing ${project.name}`,
|
|
7261
|
+
worktreePath: project.repoPath,
|
|
7262
|
+
pluginDir: resolvePluginDir(),
|
|
7263
|
+
settingsPath: artifacts.settingsPath,
|
|
7264
|
+
mcpConfigPath: artifacts.mcpConfigPath,
|
|
7265
|
+
systemPromptPath: artifacts.systemPromptPath,
|
|
7266
|
+
model: resolveModel("prepare", ctx.config, project),
|
|
7267
|
+
resumeSessionId,
|
|
7268
|
+
strictMcp: ctx.config.sessionMcp === "runcastleOnly"
|
|
7269
|
+
};
|
|
7270
|
+
if (opts.spawn === false) {
|
|
7271
|
+
emitProject(ctx, project.id, {
|
|
7272
|
+
type: "session.launched",
|
|
7273
|
+
message: "preparation session prepared (terminal spawn skipped)",
|
|
7274
|
+
data: {
|
|
7275
|
+
sessionId: session.id,
|
|
7276
|
+
command: ["claude", ...buildClaudeArgs(buildInput)].join(" "),
|
|
7277
|
+
spawned: false
|
|
7278
|
+
}
|
|
7279
|
+
});
|
|
7280
|
+
return { sessionId: session.id };
|
|
7281
|
+
}
|
|
7282
|
+
spawnEmbeddedPty(ctx, undefined, session, project.repoPath, serverUrl, buildClaudeArgs(buildInput), { resumeSessionId });
|
|
7283
|
+
return { sessionId: session.id };
|
|
7284
|
+
}
|
|
7285
|
+
async function buildPrepareBrief(ctx, project) {
|
|
7286
|
+
const findings = await listFindings(ctx, project);
|
|
7287
|
+
const notes = latestPrepNotes(ctx, project.id);
|
|
7288
|
+
return {
|
|
7289
|
+
project,
|
|
7290
|
+
remainingKeys: keysToPrepare(ctx, project),
|
|
7291
|
+
established: findings.map((f) => ({
|
|
7292
|
+
key: f.key,
|
|
7293
|
+
source: f.source,
|
|
7294
|
+
...f.evidence ? { evidence: f.evidence } : {}
|
|
7295
|
+
})),
|
|
7296
|
+
...notes ? { notes } : {}
|
|
7297
|
+
};
|
|
7298
|
+
}
|
|
5905
7299
|
async function workWaypoint(ctx, input, opts = {}) {
|
|
5906
7300
|
const feature = getFeatureRow(ctx, input.featureId);
|
|
5907
7301
|
if (!feature.mapped) {
|
|
@@ -5918,6 +7312,7 @@ async function workWaypoint(ctx, input, opts = {}) {
|
|
|
5918
7312
|
});
|
|
5919
7313
|
return { runId };
|
|
5920
7314
|
}
|
|
7315
|
+
sweepActiveSessions(ctx, feature, input.endLive === true);
|
|
5921
7316
|
assertSpawnable(ctx, feature);
|
|
5922
7317
|
return launchSession(ctx, { featureId: feature.id, kind: "waypoint", waypointId: wp.id }, opts);
|
|
5923
7318
|
}
|
|
@@ -5974,7 +7369,7 @@ function handlePtyExit(ctx, feature, session, meta, exitCode) {
|
|
|
5974
7369
|
releaseForSession(ctx, session.id);
|
|
5975
7370
|
if (diedBeforeLive && meta.resumeSessionId) {
|
|
5976
7371
|
const label = meta.waypoint ? `waypoint ${meta.waypoint.seq} (${meta.waypoint.title})` : session.kind;
|
|
5977
|
-
|
|
7372
|
+
emitForSession(ctx, session, {
|
|
5978
7373
|
type: "session.resume_failed",
|
|
5979
7374
|
message: `resume failed for ${label} \u2014 the session exited before starting (code ${exitCode ?? "unknown"}); the previous conversation is still resumable`,
|
|
5980
7375
|
data: {
|
|
@@ -5985,7 +7380,7 @@ function handlePtyExit(ctx, feature, session, meta, exitCode) {
|
|
|
5985
7380
|
}
|
|
5986
7381
|
});
|
|
5987
7382
|
}
|
|
5988
|
-
|
|
7383
|
+
emitForSession(ctx, session, {
|
|
5989
7384
|
type: "session.pty_exited",
|
|
5990
7385
|
message: ptyExitMessage(exitCode),
|
|
5991
7386
|
data: { sessionId: session.id, exitCode: exitCode ?? null }
|
|
@@ -6008,15 +7403,16 @@ function spawnEmbeddedPty(ctx, feature, session, worktreePath, serverUrl, claude
|
|
|
6008
7403
|
opts: { cwd: worktreePath, env, cols: 80, rows: 24, useConpty: true },
|
|
6009
7404
|
onExit: ({ exitCode }) => handlePtyExit(ctx, feature, session, meta, exitCode)
|
|
6010
7405
|
});
|
|
6011
|
-
|
|
7406
|
+
emitForSession(ctx, session, {
|
|
6012
7407
|
type: "session.launched",
|
|
6013
7408
|
message: "embedded terminal spawned",
|
|
6014
7409
|
data: { sessionId: session.id, mode: "embedded", pid: entry.pty.pid }
|
|
6015
7410
|
});
|
|
7411
|
+
armSessionReadyWatchdog(ctx, session);
|
|
6016
7412
|
} catch (err) {
|
|
6017
7413
|
markSessionEnded(ctx, session.id);
|
|
6018
7414
|
releaseForSession(ctx, session.id);
|
|
6019
|
-
|
|
7415
|
+
emitForSession(ctx, session, {
|
|
6020
7416
|
type: "session.spawn_failed",
|
|
6021
7417
|
message: `failed to spawn embedded terminal: ${err instanceof Error ? err.message : String(err)}`,
|
|
6022
7418
|
data: { sessionId: session.id, mode: "embedded" }
|
|
@@ -6031,6 +7427,7 @@ var init_launcher = __esm(() => {
|
|
|
6031
7427
|
init_skills_root();
|
|
6032
7428
|
init_schema();
|
|
6033
7429
|
init_errors();
|
|
7430
|
+
init_end_session();
|
|
6034
7431
|
init_registry();
|
|
6035
7432
|
init_events();
|
|
6036
7433
|
init_gates();
|
|
@@ -6039,8 +7436,11 @@ var init_launcher = __esm(() => {
|
|
|
6039
7436
|
init_tickets();
|
|
6040
7437
|
init_waypoints();
|
|
6041
7438
|
init_runner();
|
|
7439
|
+
init_findings();
|
|
7440
|
+
init_prep();
|
|
6042
7441
|
init_artifacts();
|
|
6043
7442
|
init_sessions();
|
|
7443
|
+
init_sessions();
|
|
6044
7444
|
init_end_session();
|
|
6045
7445
|
CC_NESTING_ENV = [
|
|
6046
7446
|
"CLAUDE_CODE_CHILD_SESSION",
|
|
@@ -6055,7 +7455,7 @@ var init_launcher = __esm(() => {
|
|
|
6055
7455
|
});
|
|
6056
7456
|
|
|
6057
7457
|
// src/trpc/routers/feature.ts
|
|
6058
|
-
import * as
|
|
7458
|
+
import * as z7 from "zod";
|
|
6059
7459
|
var gateId, featureRouter;
|
|
6060
7460
|
var init_feature = __esm(() => {
|
|
6061
7461
|
init_src();
|
|
@@ -6066,37 +7466,38 @@ var init_feature = __esm(() => {
|
|
|
6066
7466
|
init_git();
|
|
6067
7467
|
init_repo();
|
|
6068
7468
|
init_context();
|
|
6069
|
-
gateId =
|
|
7469
|
+
gateId = z7.enum(["G1", "G2", "G3", "G4", "G5"]);
|
|
6070
7470
|
featureRouter = router({
|
|
6071
|
-
create: publicProcedure.input(
|
|
6072
|
-
projectId:
|
|
6073
|
-
title:
|
|
6074
|
-
oneLiner:
|
|
6075
|
-
baseBranch:
|
|
7471
|
+
create: publicProcedure.input(z7.object({
|
|
7472
|
+
projectId: z7.string(),
|
|
7473
|
+
title: z7.string().min(1),
|
|
7474
|
+
oneLiner: z7.string(),
|
|
7475
|
+
baseBranch: z7.string().optional()
|
|
6076
7476
|
})).mutation(({ ctx, input }) => createFeature(ctx, input)),
|
|
6077
|
-
list: publicProcedure.input(
|
|
6078
|
-
get: publicProcedure.input(
|
|
6079
|
-
launchSession: publicProcedure.input(
|
|
6080
|
-
featureId:
|
|
7477
|
+
list: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => list(ctx, input.projectId)),
|
|
7478
|
+
get: publicProcedure.input(z7.object({ id: z7.string() })).query(({ ctx, input }) => getFeatureFull(ctx, input.id)),
|
|
7479
|
+
launchSession: publicProcedure.input(z7.object({
|
|
7480
|
+
featureId: z7.string(),
|
|
6081
7481
|
kind: SessionKind,
|
|
6082
|
-
kickoffLine:
|
|
7482
|
+
kickoffLine: z7.string().min(1).optional()
|
|
6083
7483
|
})).mutation(({ ctx, input }) => launchSession(ctx, input)),
|
|
6084
|
-
workWaypoint: publicProcedure.input(
|
|
6085
|
-
converge: publicProcedure.input(
|
|
6086
|
-
|
|
6087
|
-
|
|
6088
|
-
|
|
6089
|
-
|
|
6090
|
-
|
|
6091
|
-
|
|
6092
|
-
|
|
6093
|
-
|
|
7484
|
+
workWaypoint: publicProcedure.input(z7.object({ featureId: z7.string(), waypointId: z7.string(), endLive: z7.boolean().optional() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
|
|
7485
|
+
converge: publicProcedure.input(z7.object({ featureId: z7.string(), overrideReason: z7.string().min(1).optional() })).mutation(({ ctx, input }) => converge(ctx, { featureId: input.featureId, overrideReason: input.overrideReason })),
|
|
7486
|
+
resendKickoff: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => resendKickoff(ctx, input.sessionId)),
|
|
7487
|
+
endSession: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => endSession(ctx, input.sessionId)),
|
|
7488
|
+
advance: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => advance(ctx, input.featureId)),
|
|
7489
|
+
overrideGate: publicProcedure.input(z7.object({ featureId: z7.string(), gate: gateId, reason: z7.string().min(1) })).mutation(({ ctx, input }) => overrideGate(ctx, input.featureId, input.gate, input.reason)),
|
|
7490
|
+
archive: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => archiveFeature(ctx, input.featureId)),
|
|
7491
|
+
unarchive: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => unarchiveFeature(ctx, input.featureId)),
|
|
7492
|
+
delete: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => deleteFeature(ctx, input.featureId)),
|
|
7493
|
+
burn: publicProcedure.input(z7.object({ featureId: z7.string(), model: z7.string().min(1).optional() })).mutation(({ ctx, input }) => burn(ctx, input.featureId, { modelOverride: input.model })),
|
|
7494
|
+
testDrive: publicProcedure.input(z7.object({ featureId: z7.string(), action: z7.enum(["start", "stop"]) })).mutation(async ({ ctx, input }) => {
|
|
6094
7495
|
const feature = getFeatureRow(ctx, input.featureId);
|
|
6095
7496
|
const project = projectForFeature(ctx, feature);
|
|
6096
7497
|
return testDrive(ctx, project, feature, input.action);
|
|
6097
7498
|
}),
|
|
6098
7499
|
driveInfo: publicProcedure.query(() => activeDriveInfo()),
|
|
6099
|
-
merge: publicProcedure.input(
|
|
7500
|
+
merge: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(async ({ ctx, input }) => {
|
|
6100
7501
|
const feature = getFeatureRow(ctx, input.featureId);
|
|
6101
7502
|
const project = projectForFeature(ctx, feature);
|
|
6102
7503
|
if (activeTestDriveFeatureId() === feature.id) {
|
|
@@ -6119,9 +7520,9 @@ var init_feature = __esm(() => {
|
|
|
6119
7520
|
});
|
|
6120
7521
|
|
|
6121
7522
|
// src/services/fsbrowse.ts
|
|
6122
|
-
import { existsSync as
|
|
7523
|
+
import { existsSync as existsSync15, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
|
|
6123
7524
|
import { homedir as homedir2 } from "os";
|
|
6124
|
-
import { basename, dirname as
|
|
7525
|
+
import { basename, dirname as dirname7, isAbsolute, join as join15, resolve as resolve9, sep as sep3 } from "path";
|
|
6125
7526
|
function expandPath(input, home = homedir2()) {
|
|
6126
7527
|
const trimmed = input.trim();
|
|
6127
7528
|
if (trimmed === "")
|
|
@@ -6130,12 +7531,12 @@ function expandPath(input, home = homedir2()) {
|
|
|
6130
7531
|
if (out === "~")
|
|
6131
7532
|
out = home;
|
|
6132
7533
|
else if (out.startsWith("~/") || out.startsWith("~\\"))
|
|
6133
|
-
out =
|
|
6134
|
-
return
|
|
7534
|
+
out = join15(home, out.slice(2));
|
|
7535
|
+
return resolve9(out);
|
|
6135
7536
|
}
|
|
6136
7537
|
function looksLikeRepo(dir) {
|
|
6137
7538
|
try {
|
|
6138
|
-
return
|
|
7539
|
+
return existsSync15(join15(dir, ".git"));
|
|
6139
7540
|
} catch {
|
|
6140
7541
|
return false;
|
|
6141
7542
|
}
|
|
@@ -6146,20 +7547,20 @@ function listRoots(home = homedir2()) {
|
|
|
6146
7547
|
for (let code = 67;code <= 90; code++) {
|
|
6147
7548
|
const drive = `${String.fromCharCode(code)}:${sep3}`;
|
|
6148
7549
|
try {
|
|
6149
|
-
if (
|
|
7550
|
+
if (existsSync15(drive))
|
|
6150
7551
|
roots.push({ label: drive.replace(/\\$/, ""), path: drive, kind: "drive" });
|
|
6151
7552
|
} catch {}
|
|
6152
7553
|
}
|
|
6153
7554
|
} else {
|
|
6154
7555
|
roots.push({ label: "/", path: "/", kind: "drive" });
|
|
6155
7556
|
}
|
|
6156
|
-
if (
|
|
7557
|
+
if (existsSync15(home))
|
|
6157
7558
|
roots.push({ label: "Home", path: home, kind: "home" });
|
|
6158
7559
|
const seen = new Set(roots.map(pathKey));
|
|
6159
7560
|
for (const name of COMMON_DIRS) {
|
|
6160
|
-
const candidate =
|
|
7561
|
+
const candidate = join15(home, name);
|
|
6161
7562
|
const key = pathKey({ path: candidate });
|
|
6162
|
-
if (seen.has(key) || !
|
|
7563
|
+
if (seen.has(key) || !existsSync15(candidate))
|
|
6163
7564
|
continue;
|
|
6164
7565
|
seen.add(key);
|
|
6165
7566
|
roots.push({ label: name, path: candidate, kind: "common" });
|
|
@@ -6174,7 +7575,7 @@ function crumbsFor(dir) {
|
|
|
6174
7575
|
let cur = dir;
|
|
6175
7576
|
for (;; ) {
|
|
6176
7577
|
out.unshift({ name: basename(cur) || cur, path: cur });
|
|
6177
|
-
const parent =
|
|
7578
|
+
const parent = dirname7(cur);
|
|
6178
7579
|
if (parent === cur)
|
|
6179
7580
|
break;
|
|
6180
7581
|
cur = parent;
|
|
@@ -6182,7 +7583,7 @@ function crumbsFor(dir) {
|
|
|
6182
7583
|
return out;
|
|
6183
7584
|
}
|
|
6184
7585
|
function parentOf(dir) {
|
|
6185
|
-
const parent =
|
|
7586
|
+
const parent = dirname7(dir);
|
|
6186
7587
|
return parent === dir ? null : parent;
|
|
6187
7588
|
}
|
|
6188
7589
|
function browseDir(input, showHidden = false, home = homedir2()) {
|
|
@@ -6190,13 +7591,13 @@ function browseDir(input, showHidden = false, home = homedir2()) {
|
|
|
6190
7591
|
if (!isAbsolute(target)) {
|
|
6191
7592
|
throw new InvalidInputError(`path is not absolute: ${target}`);
|
|
6192
7593
|
}
|
|
6193
|
-
if (!
|
|
7594
|
+
if (!existsSync15(target)) {
|
|
6194
7595
|
throw new InvalidInputError(`path does not exist: ${target}`);
|
|
6195
7596
|
}
|
|
6196
7597
|
let dir = target;
|
|
6197
7598
|
try {
|
|
6198
7599
|
if (!statSync2(target).isDirectory())
|
|
6199
|
-
dir =
|
|
7600
|
+
dir = dirname7(target);
|
|
6200
7601
|
} catch (e) {
|
|
6201
7602
|
throw new InvalidInputError(`cannot read path: ${target} (${errMsg2(e)})`);
|
|
6202
7603
|
}
|
|
@@ -6218,7 +7619,7 @@ function browseDir(input, showHidden = false, home = homedir2()) {
|
|
|
6218
7619
|
continue;
|
|
6219
7620
|
if (!showHidden && name.startsWith("."))
|
|
6220
7621
|
continue;
|
|
6221
|
-
const path =
|
|
7622
|
+
const path = join15(dir, name);
|
|
6222
7623
|
const isSymlink = dirent.isSymbolicLink();
|
|
6223
7624
|
let isDirectory = dirent.isDirectory();
|
|
6224
7625
|
if (isSymlink) {
|
|
@@ -6253,9 +7654,9 @@ var init_fsbrowse = __esm(() => {
|
|
|
6253
7654
|
});
|
|
6254
7655
|
|
|
6255
7656
|
// src/services/projects.ts
|
|
6256
|
-
import { existsSync as
|
|
6257
|
-
import { basename as basename2, join as
|
|
6258
|
-
import { eq as
|
|
7657
|
+
import { existsSync as existsSync16 } from "fs";
|
|
7658
|
+
import { basename as basename2, join as join16 } from "path";
|
|
7659
|
+
import { eq as eq11, isNull } from "drizzle-orm";
|
|
6259
7660
|
function listProjects(ctx) {
|
|
6260
7661
|
return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
|
|
6261
7662
|
}
|
|
@@ -6277,13 +7678,14 @@ async function openProject(ctx, rawPath) {
|
|
|
6277
7678
|
const name = basename2(repoPath) || repoPath;
|
|
6278
7679
|
const existing = getProjectByRepoPath(ctx, repoPath) ?? findProjectByCanonPath(ctx, repoPath);
|
|
6279
7680
|
if (existing) {
|
|
6280
|
-
ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(
|
|
7681
|
+
ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(eq11(projects.id, existing.id)).run();
|
|
6281
7682
|
const reopened = { ...existing, mainBranch, repoPath };
|
|
6282
7683
|
emitProject(ctx, existing.id, {
|
|
6283
7684
|
type: "project.opened",
|
|
6284
7685
|
message: `project ${existing.name} re-opened at ${repoPath} (${mainBranch})`
|
|
6285
7686
|
});
|
|
6286
7687
|
warnIfTranslatedMount(ctx, existing.id, repoPath);
|
|
7688
|
+
maybeAutoPrepare(ctx, reopened);
|
|
6287
7689
|
return reopened;
|
|
6288
7690
|
}
|
|
6289
7691
|
const row = {
|
|
@@ -6301,14 +7703,25 @@ async function openProject(ctx, rawPath) {
|
|
|
6301
7703
|
message: `project ${name} at ${repoPath} (${mainBranch})`
|
|
6302
7704
|
});
|
|
6303
7705
|
warnIfTranslatedMount(ctx, project.id, repoPath);
|
|
7706
|
+
maybeAutoPrepare(ctx, project);
|
|
6304
7707
|
return project;
|
|
6305
7708
|
}
|
|
7709
|
+
function maybeAutoPrepare(ctx, project) {
|
|
7710
|
+
if (!shouldAutoPrepare(ctx, project))
|
|
7711
|
+
return;
|
|
7712
|
+
startPrep(ctx, project.id).catch((e) => {
|
|
7713
|
+
emitProject(ctx, project.id, {
|
|
7714
|
+
type: "prep.run.failed",
|
|
7715
|
+
message: `could not start preparation: ${e instanceof Error ? e.message : String(e)}`
|
|
7716
|
+
});
|
|
7717
|
+
});
|
|
7718
|
+
}
|
|
6306
7719
|
function closeProject(ctx, projectId) {
|
|
6307
7720
|
const project = requireProjectById(ctx, projectId);
|
|
6308
7721
|
if (projectHasActiveRun(ctx, projectId)) {
|
|
6309
7722
|
throw new InvalidInputError(`cannot close ${project.name}: a run is in flight \u2014 wait for it to finish or cancel it first`);
|
|
6310
7723
|
}
|
|
6311
|
-
ctx.db.update(projects).set({ closedAt: Date.now() }).where(
|
|
7724
|
+
ctx.db.update(projects).set({ closedAt: Date.now() }).where(eq11(projects.id, projectId)).run();
|
|
6312
7725
|
emitProject(ctx, projectId, {
|
|
6313
7726
|
type: "project.closed",
|
|
6314
7727
|
message: `project ${project.name} closed`
|
|
@@ -6317,7 +7730,7 @@ function closeProject(ctx, projectId) {
|
|
|
6317
7730
|
}
|
|
6318
7731
|
function renameProject(ctx, projectId, name) {
|
|
6319
7732
|
const project = requireProjectById(ctx, projectId);
|
|
6320
|
-
ctx.db.update(projects).set({ name }).where(
|
|
7733
|
+
ctx.db.update(projects).set({ name }).where(eq11(projects.id, projectId)).run();
|
|
6321
7734
|
emitProject(ctx, projectId, {
|
|
6322
7735
|
type: "project.renamed",
|
|
6323
7736
|
message: `project renamed ${project.name} \u2192 ${name}`,
|
|
@@ -6335,7 +7748,7 @@ async function assertRepoTolerant(ctx, repoPath) {
|
|
|
6335
7748
|
} catch (e) {
|
|
6336
7749
|
if (!isNotImplemented(e))
|
|
6337
7750
|
throw e;
|
|
6338
|
-
if (!
|
|
7751
|
+
if (!existsSync16(join16(repoPath, ".git"))) {
|
|
6339
7752
|
throw new InvalidInputError(`not a git repository: ${repoPath}`);
|
|
6340
7753
|
}
|
|
6341
7754
|
}
|
|
@@ -6356,31 +7769,63 @@ var init_projects = __esm(() => {
|
|
|
6356
7769
|
init_events();
|
|
6357
7770
|
init_fsbrowse();
|
|
6358
7771
|
init_git();
|
|
7772
|
+
init_prep();
|
|
6359
7773
|
init_repo();
|
|
6360
7774
|
});
|
|
6361
7775
|
|
|
6362
7776
|
// src/trpc/routers/project.ts
|
|
6363
|
-
import * as
|
|
7777
|
+
import * as z8 from "zod";
|
|
6364
7778
|
var projectRouter;
|
|
6365
7779
|
var init_project = __esm(() => {
|
|
7780
|
+
init_src();
|
|
7781
|
+
init_findings();
|
|
6366
7782
|
init_fsbrowse();
|
|
6367
7783
|
init_git();
|
|
7784
|
+
init_prep();
|
|
7785
|
+
init_launcher();
|
|
7786
|
+
init_sessions();
|
|
6368
7787
|
init_projects();
|
|
6369
7788
|
init_repo();
|
|
6370
7789
|
init_context();
|
|
6371
7790
|
projectRouter = router({
|
|
6372
7791
|
list: publicProcedure.query(({ ctx }) => listProjects(ctx)),
|
|
6373
7792
|
roots: publicProcedure.query(() => listRoots()),
|
|
6374
|
-
browse: publicProcedure.input(
|
|
6375
|
-
branches: publicProcedure.input(
|
|
6376
|
-
open: publicProcedure.input(
|
|
6377
|
-
close: publicProcedure.input(
|
|
6378
|
-
rename: publicProcedure.input(
|
|
7793
|
+
browse: publicProcedure.input(z8.object({ dir: z8.string().optional(), showHidden: z8.boolean().optional() }).optional()).query(({ input }) => browseDir(input?.dir, input?.showHidden ?? false)),
|
|
7794
|
+
branches: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => listBranches(requireProjectById(ctx, input.projectId))),
|
|
7795
|
+
open: publicProcedure.input(z8.object({ repoPath: z8.string().min(1) })).mutation(({ ctx, input }) => openProject(ctx, input.repoPath)),
|
|
7796
|
+
close: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ ctx, input }) => closeProject(ctx, input.projectId)),
|
|
7797
|
+
rename: publicProcedure.input(z8.object({ projectId: z8.string(), name: z8.string().min(1) })).mutation(({ ctx, input }) => renameProject(ctx, input.projectId, input.name)),
|
|
7798
|
+
prepare: publicProcedure.input(z8.object({
|
|
7799
|
+
projectId: z8.string(),
|
|
7800
|
+
refresh: z8.boolean().optional(),
|
|
7801
|
+
keys: z8.array(PreparedKey).optional()
|
|
7802
|
+
})).mutation(async ({ ctx, input }) => {
|
|
7803
|
+
const { prepId, keys } = await startPrep(ctx, input.projectId, {
|
|
7804
|
+
...input.refresh !== undefined ? { refresh: input.refresh } : {},
|
|
7805
|
+
...input.keys ? { keys: input.keys } : {}
|
|
7806
|
+
});
|
|
7807
|
+
return { prepId, keys };
|
|
7808
|
+
}),
|
|
7809
|
+
talkToPrep: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ ctx, input }) => launchPrepareSession(ctx, { projectId: input.projectId })),
|
|
7810
|
+
prepSession: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "prepare")),
|
|
7811
|
+
cancelPrepare: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ input }) => {
|
|
7812
|
+
cancelPrep(input.projectId);
|
|
7813
|
+
return { ok: true };
|
|
7814
|
+
}),
|
|
7815
|
+
prep: publicProcedure.input(z8.object({ projectId: z8.string() })).query(async ({ ctx, input }) => {
|
|
7816
|
+
const project = requireProjectById(ctx, input.projectId);
|
|
7817
|
+
return {
|
|
7818
|
+
latest: latestPrep(ctx, project.id),
|
|
7819
|
+
running: isPreparing(project.id),
|
|
7820
|
+
pendingKeys: keysToPrepare(ctx, project),
|
|
7821
|
+
findings: await listFindings(ctx, project)
|
|
7822
|
+
};
|
|
7823
|
+
})
|
|
6379
7824
|
});
|
|
6380
7825
|
});
|
|
6381
7826
|
|
|
6382
7827
|
// src/trpc/routers/run.ts
|
|
6383
|
-
import * as
|
|
7828
|
+
import * as z9 from "zod";
|
|
6384
7829
|
var runRouter;
|
|
6385
7830
|
var init_run = __esm(() => {
|
|
6386
7831
|
init_agent_stream();
|
|
@@ -6388,9 +7833,9 @@ var init_run = __esm(() => {
|
|
|
6388
7833
|
init_runner();
|
|
6389
7834
|
init_context();
|
|
6390
7835
|
runRouter = router({
|
|
6391
|
-
get: publicProcedure.input(
|
|
6392
|
-
agentTranscript: publicProcedure.input(
|
|
6393
|
-
cancel: publicProcedure.input(
|
|
7836
|
+
get: publicProcedure.input(z9.object({ runId: z9.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
|
|
7837
|
+
agentTranscript: publicProcedure.input(z9.object({ ticketId: z9.string(), after: z9.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
|
|
7838
|
+
cancel: publicProcedure.input(z9.object({ runId: z9.string() })).mutation(({ input }) => {
|
|
6394
7839
|
cancelRun(input.runId);
|
|
6395
7840
|
return { ok: true };
|
|
6396
7841
|
})
|
|
@@ -6398,15 +7843,15 @@ var init_run = __esm(() => {
|
|
|
6398
7843
|
});
|
|
6399
7844
|
|
|
6400
7845
|
// src/services/settings.ts
|
|
6401
|
-
import { existsSync as
|
|
6402
|
-
import { dirname as
|
|
6403
|
-
import * as
|
|
6404
|
-
import { eq as
|
|
7846
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync7, writeFileSync as writeFileSync3 } from "fs";
|
|
7847
|
+
import { dirname as dirname8 } from "path";
|
|
7848
|
+
import * as z10 from "zod";
|
|
7849
|
+
import { eq as eq12 } from "drizzle-orm";
|
|
6405
7850
|
function readRawConfig(configFile) {
|
|
6406
|
-
if (!
|
|
7851
|
+
if (!existsSync17(configFile))
|
|
6407
7852
|
return {};
|
|
6408
7853
|
try {
|
|
6409
|
-
const parsed = JSON.parse(
|
|
7854
|
+
const parsed = JSON.parse(readFileSync7(configFile, "utf8"));
|
|
6410
7855
|
if (typeof parsed !== "object" || parsed === null)
|
|
6411
7856
|
return {};
|
|
6412
7857
|
return foldLegacyModelConfig(parsed);
|
|
@@ -6430,33 +7875,51 @@ function stepModelFields(fileRaw) {
|
|
|
6430
7875
|
});
|
|
6431
7876
|
}
|
|
6432
7877
|
function projectOverrides(ctx, projectId) {
|
|
6433
|
-
const row = ctx.db.select({
|
|
7878
|
+
const row = ctx.db.select({
|
|
7879
|
+
model: projects.model,
|
|
7880
|
+
sandbox: projects.sandbox,
|
|
7881
|
+
devCommand: projects.devCommand,
|
|
7882
|
+
setupCommand: projects.setupCommand,
|
|
7883
|
+
verifyCommands: projects.verifyCommands,
|
|
7884
|
+
knownFailures: projects.knownFailures,
|
|
7885
|
+
dbResetCommand: projects.dbResetCommand,
|
|
7886
|
+
driveSetupCommand: projects.driveSetupCommand,
|
|
7887
|
+
driveStopCommand: projects.driveStopCommand,
|
|
7888
|
+
driveEnv: projects.driveEnv
|
|
7889
|
+
}).from(projects).where(eq12(projects.id, projectId)).get();
|
|
6434
7890
|
return {
|
|
6435
7891
|
model: row?.model ?? null,
|
|
6436
7892
|
sandbox: row?.sandbox ?? null,
|
|
6437
|
-
devCommand: row?.devCommand ?? null
|
|
7893
|
+
devCommand: row?.devCommand ?? null,
|
|
7894
|
+
setupCommand: row?.setupCommand ?? null,
|
|
7895
|
+
verifyCommands: row?.verifyCommands ?? null,
|
|
7896
|
+
knownFailures: row?.knownFailures ?? null,
|
|
7897
|
+
dbResetCommand: row?.dbResetCommand ?? null,
|
|
7898
|
+
driveSetupCommand: row?.driveSetupCommand ?? null,
|
|
7899
|
+
driveStopCommand: row?.driveStopCommand ?? null,
|
|
7900
|
+
driveEnv: row?.driveEnv ?? null
|
|
6438
7901
|
};
|
|
6439
7902
|
}
|
|
6440
|
-
function resolveField(
|
|
7903
|
+
function resolveField(desc5, layers) {
|
|
6441
7904
|
const scoped = layers.overrides !== null;
|
|
6442
|
-
const scope = scoped &&
|
|
6443
|
-
const base = { key:
|
|
6444
|
-
if (
|
|
6445
|
-
const raw = layers.env[
|
|
7905
|
+
const scope = scoped && desc5.projectColumn ? "project" : "global";
|
|
7906
|
+
const base = { key: desc5.key, restartRequired: desc5.restartRequired, scope };
|
|
7907
|
+
if (desc5.envVar) {
|
|
7908
|
+
const raw = layers.env[desc5.envVar];
|
|
6446
7909
|
if (raw !== undefined && raw !== "") {
|
|
6447
|
-
return { ...base, value:
|
|
7910
|
+
return { ...base, value: desc5.parseEnv(raw), source: "env", editable: false };
|
|
6448
7911
|
}
|
|
6449
7912
|
}
|
|
6450
|
-
if (layers.overrides &&
|
|
6451
|
-
const ov = layers.overrides[
|
|
7913
|
+
if (layers.overrides && desc5.projectColumn) {
|
|
7914
|
+
const ov = layers.overrides[desc5.projectColumn];
|
|
6452
7915
|
if (ov !== null && ov !== "") {
|
|
6453
7916
|
return { ...base, value: ov, source: "project", editable: true };
|
|
6454
7917
|
}
|
|
6455
7918
|
}
|
|
6456
|
-
if (
|
|
6457
|
-
return { ...base, value: layers.fileRaw[
|
|
7919
|
+
if (desc5.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc5.configKey)) {
|
|
7920
|
+
return { ...base, value: layers.fileRaw[desc5.configKey], source: "file", editable: true };
|
|
6458
7921
|
}
|
|
6459
|
-
const value =
|
|
7922
|
+
const value = desc5.configKey ? DEFAULTS[desc5.configKey] ?? null : null;
|
|
6460
7923
|
return { ...base, value, source: "default", editable: true };
|
|
6461
7924
|
}
|
|
6462
7925
|
function getSettings(ctx, projectId, io = {}) {
|
|
@@ -6473,59 +7936,61 @@ function updateSettings(ctx, input, io = {}) {
|
|
|
6473
7936
|
if (input.key.startsWith("stepModels.")) {
|
|
6474
7937
|
return updateStepModel(ctx, input, configFile, io);
|
|
6475
7938
|
}
|
|
6476
|
-
const
|
|
6477
|
-
if (!
|
|
7939
|
+
const desc5 = DESCRIPTORS.find((d) => d.key === input.key);
|
|
7940
|
+
if (!desc5)
|
|
6478
7941
|
throw new InvalidInputError(`unknown setting: ${input.key}`);
|
|
6479
|
-
if (
|
|
6480
|
-
const raw = env[
|
|
7942
|
+
if (desc5.envVar) {
|
|
7943
|
+
const raw = env[desc5.envVar];
|
|
6481
7944
|
if (raw !== undefined && raw !== "") {
|
|
6482
|
-
throw new InvalidInputError(`${
|
|
7945
|
+
throw new InvalidInputError(`${desc5.key} is set by environment variable ${desc5.envVar}; unset it to edit`);
|
|
6483
7946
|
}
|
|
6484
7947
|
}
|
|
6485
|
-
const toProject = input.projectId !== undefined &&
|
|
7948
|
+
const toProject = input.projectId !== undefined && desc5.projectColumn !== undefined;
|
|
6486
7949
|
if (input.value === null) {
|
|
6487
7950
|
if (!toProject)
|
|
6488
|
-
throw new InvalidInputError(`${
|
|
7951
|
+
throw new InvalidInputError(`${desc5.key} cannot be cleared`);
|
|
6489
7952
|
const project = requireProjectById(ctx, input.projectId);
|
|
6490
|
-
ctx.db.update(projects).set({ [
|
|
7953
|
+
ctx.db.update(projects).set({ [desc5.projectColumn]: null }).where(eq12(projects.id, project.id)).run();
|
|
7954
|
+
recordHuman(ctx, project.id, desc5.key, null);
|
|
6491
7955
|
emitProject(ctx, project.id, {
|
|
6492
7956
|
type: "settings.updated",
|
|
6493
|
-
message: `${
|
|
6494
|
-
data: { key:
|
|
7957
|
+
message: `${desc5.key} override cleared`,
|
|
7958
|
+
data: { key: desc5.key, scope: "project", value: null }
|
|
6495
7959
|
});
|
|
6496
|
-
return field(getSettings(ctx, input.projectId, io),
|
|
7960
|
+
return field(getSettings(ctx, input.projectId, io), desc5.key);
|
|
6497
7961
|
}
|
|
6498
|
-
const parsed =
|
|
7962
|
+
const parsed = desc5.valueSchema.safeParse(input.value);
|
|
6499
7963
|
if (!parsed.success) {
|
|
6500
|
-
throw new InvalidInputError(`invalid value for ${
|
|
7964
|
+
throw new InvalidInputError(`invalid value for ${desc5.key}: ${parsed.error.issues[0]?.message}`);
|
|
6501
7965
|
}
|
|
6502
7966
|
const value = parsed.data;
|
|
6503
7967
|
if (toProject) {
|
|
6504
7968
|
const project = requireProjectById(ctx, input.projectId);
|
|
6505
|
-
ctx.db.update(projects).set({ [
|
|
7969
|
+
ctx.db.update(projects).set({ [desc5.projectColumn]: String(value) }).where(eq12(projects.id, project.id)).run();
|
|
7970
|
+
recordHuman(ctx, project.id, desc5.key, String(value));
|
|
6506
7971
|
emitProject(ctx, project.id, {
|
|
6507
7972
|
type: "settings.updated",
|
|
6508
|
-
message: `${
|
|
6509
|
-
data: { key:
|
|
7973
|
+
message: `${desc5.key} override set to ${String(value)}`,
|
|
7974
|
+
data: { key: desc5.key, scope: "project", value }
|
|
6510
7975
|
});
|
|
6511
|
-
return field(getSettings(ctx, input.projectId, io),
|
|
7976
|
+
return field(getSettings(ctx, input.projectId, io), desc5.key);
|
|
6512
7977
|
}
|
|
6513
|
-
if (!
|
|
6514
|
-
throw new InvalidInputError(`${
|
|
7978
|
+
if (!desc5.configKey) {
|
|
7979
|
+
throw new InvalidInputError(`${desc5.key} is a per-project setting; provide a projectId`);
|
|
6515
7980
|
}
|
|
6516
|
-
writeGlobal(configFile,
|
|
6517
|
-
ctx.config[
|
|
7981
|
+
writeGlobal(configFile, desc5.configKey, value);
|
|
7982
|
+
ctx.config[desc5.configKey] = value;
|
|
6518
7983
|
emitProject(ctx, GLOBAL_EVENT_KEY, {
|
|
6519
7984
|
type: "settings.updated",
|
|
6520
|
-
message: `${
|
|
6521
|
-
data: { key:
|
|
7985
|
+
message: `${desc5.key} set to ${String(value)}`,
|
|
7986
|
+
data: { key: desc5.key, scope: "global", value }
|
|
6522
7987
|
});
|
|
6523
|
-
return field(getSettings(ctx, input.projectId, io),
|
|
7988
|
+
return field(getSettings(ctx, input.projectId, io), desc5.key);
|
|
6524
7989
|
}
|
|
6525
7990
|
function writeGlobal(configFile, configKey, value) {
|
|
6526
7991
|
const raw = readRawConfig(configFile);
|
|
6527
7992
|
raw[configKey] = value;
|
|
6528
|
-
|
|
7993
|
+
mkdirSync8(dirname8(configFile), { recursive: true });
|
|
6529
7994
|
writeFileSync3(configFile, `${JSON.stringify(raw, null, 2)}
|
|
6530
7995
|
`);
|
|
6531
7996
|
}
|
|
@@ -6536,7 +8001,7 @@ function updateStepModel(ctx, input, configFile, io) {
|
|
|
6536
8001
|
const modelStep = step;
|
|
6537
8002
|
let value = null;
|
|
6538
8003
|
if (input.value !== null) {
|
|
6539
|
-
const parsed =
|
|
8004
|
+
const parsed = z10.string().min(1).safeParse(input.value);
|
|
6540
8005
|
if (!parsed.success) {
|
|
6541
8006
|
throw new InvalidInputError(`invalid value for ${input.key}: ${parsed.error.issues[0]?.message}`);
|
|
6542
8007
|
}
|
|
@@ -6564,7 +8029,7 @@ function writeStepModel(configFile, step, value) {
|
|
|
6564
8029
|
else
|
|
6565
8030
|
stepModels[step] = value;
|
|
6566
8031
|
raw.stepModels = stepModels;
|
|
6567
|
-
|
|
8032
|
+
mkdirSync8(dirname8(configFile), { recursive: true });
|
|
6568
8033
|
writeFileSync3(configFile, `${JSON.stringify(raw, null, 2)}
|
|
6569
8034
|
`);
|
|
6570
8035
|
}
|
|
@@ -6581,6 +8046,7 @@ var init_settings = __esm(() => {
|
|
|
6581
8046
|
init_schema();
|
|
6582
8047
|
init_errors();
|
|
6583
8048
|
init_events();
|
|
8049
|
+
init_findings();
|
|
6584
8050
|
init_repo();
|
|
6585
8051
|
DESCRIPTORS = [
|
|
6586
8052
|
{
|
|
@@ -6588,7 +8054,7 @@ var init_settings = __esm(() => {
|
|
|
6588
8054
|
configKey: "serverPort",
|
|
6589
8055
|
envVar: "RUNCASTLE_SERVER_PORT",
|
|
6590
8056
|
restartRequired: true,
|
|
6591
|
-
valueSchema:
|
|
8057
|
+
valueSchema: z10.number().int().positive(),
|
|
6592
8058
|
parseEnv: (raw) => Number(raw)
|
|
6593
8059
|
},
|
|
6594
8060
|
{
|
|
@@ -6597,7 +8063,7 @@ var init_settings = __esm(() => {
|
|
|
6597
8063
|
envVar: "RUNCASTLE_MODEL",
|
|
6598
8064
|
projectColumn: "model",
|
|
6599
8065
|
restartRequired: false,
|
|
6600
|
-
valueSchema:
|
|
8066
|
+
valueSchema: z10.string().min(1),
|
|
6601
8067
|
parseEnv: idEnv
|
|
6602
8068
|
},
|
|
6603
8069
|
{
|
|
@@ -6606,7 +8072,7 @@ var init_settings = __esm(() => {
|
|
|
6606
8072
|
envVar: "RUNCASTLE_SANDBOX",
|
|
6607
8073
|
projectColumn: "sandbox",
|
|
6608
8074
|
restartRequired: false,
|
|
6609
|
-
valueSchema:
|
|
8075
|
+
valueSchema: z10.enum(["docker", "podman", "noSandbox"]),
|
|
6610
8076
|
parseEnv: idEnv
|
|
6611
8077
|
},
|
|
6612
8078
|
{
|
|
@@ -6614,7 +8080,15 @@ var init_settings = __esm(() => {
|
|
|
6614
8080
|
configKey: "sandboxImage",
|
|
6615
8081
|
envVar: "RUNCASTLE_SANDBOX_IMAGE",
|
|
6616
8082
|
restartRequired: false,
|
|
6617
|
-
valueSchema:
|
|
8083
|
+
valueSchema: z10.string().min(1),
|
|
8084
|
+
parseEnv: idEnv
|
|
8085
|
+
},
|
|
8086
|
+
{
|
|
8087
|
+
key: "sessionMcp",
|
|
8088
|
+
configKey: "sessionMcp",
|
|
8089
|
+
envVar: "RUNCASTLE_SESSION_MCP",
|
|
8090
|
+
restartRequired: false,
|
|
8091
|
+
valueSchema: z10.enum(["inherit", "runcastleOnly"]),
|
|
6618
8092
|
parseEnv: idEnv
|
|
6619
8093
|
},
|
|
6620
8094
|
{
|
|
@@ -6622,7 +8096,7 @@ var init_settings = __esm(() => {
|
|
|
6622
8096
|
configKey: "burnConcurrency",
|
|
6623
8097
|
envVar: "RUNCASTLE_BURN_CONCURRENCY",
|
|
6624
8098
|
restartRequired: false,
|
|
6625
|
-
valueSchema:
|
|
8099
|
+
valueSchema: z10.number().int().min(1).max(8),
|
|
6626
8100
|
parseEnv: (raw) => Number(raw)
|
|
6627
8101
|
},
|
|
6628
8102
|
{
|
|
@@ -6630,7 +8104,7 @@ var init_settings = __esm(() => {
|
|
|
6630
8104
|
configKey: "burnMaxIterations",
|
|
6631
8105
|
envVar: "RUNCASTLE_BURN_MAX_ITERATIONS",
|
|
6632
8106
|
restartRequired: false,
|
|
6633
|
-
valueSchema:
|
|
8107
|
+
valueSchema: z10.number().int().min(1).max(10),
|
|
6634
8108
|
parseEnv: (raw) => Number(raw)
|
|
6635
8109
|
},
|
|
6636
8110
|
{
|
|
@@ -6638,7 +8112,7 @@ var init_settings = __esm(() => {
|
|
|
6638
8112
|
configKey: "burnAttempts",
|
|
6639
8113
|
envVar: "RUNCASTLE_BURN_ATTEMPTS",
|
|
6640
8114
|
restartRequired: false,
|
|
6641
|
-
valueSchema:
|
|
8115
|
+
valueSchema: z10.number().int().min(1).max(5),
|
|
6642
8116
|
parseEnv: (raw) => Number(raw)
|
|
6643
8117
|
},
|
|
6644
8118
|
{
|
|
@@ -6646,7 +8120,7 @@ var init_settings = __esm(() => {
|
|
|
6646
8120
|
configKey: "burnConflictAttempts",
|
|
6647
8121
|
envVar: "RUNCASTLE_BURN_CONFLICT_ATTEMPTS",
|
|
6648
8122
|
restartRequired: false,
|
|
6649
|
-
valueSchema:
|
|
8123
|
+
valueSchema: z10.number().int().min(0).max(3),
|
|
6650
8124
|
parseEnv: (raw) => Number(raw)
|
|
6651
8125
|
},
|
|
6652
8126
|
{
|
|
@@ -6654,31 +8128,42 @@ var init_settings = __esm(() => {
|
|
|
6654
8128
|
configKey: "burnCpus",
|
|
6655
8129
|
envVar: "RUNCASTLE_BURN_CPUS",
|
|
6656
8130
|
restartRequired: false,
|
|
6657
|
-
valueSchema:
|
|
8131
|
+
valueSchema: z10.number().positive().max(256),
|
|
6658
8132
|
parseEnv: (raw) => Number(raw)
|
|
6659
8133
|
},
|
|
8134
|
+
{
|
|
8135
|
+
key: "autoPrepare",
|
|
8136
|
+
configKey: "autoPrepare",
|
|
8137
|
+
envVar: "RUNCASTLE_AUTO_PREPARE",
|
|
8138
|
+
restartRequired: false,
|
|
8139
|
+
valueSchema: z10.boolean(),
|
|
8140
|
+
parseEnv: (raw) => raw !== "0" && raw.toLowerCase() !== "false"
|
|
8141
|
+
},
|
|
6660
8142
|
{
|
|
6661
8143
|
key: "setupCommand",
|
|
6662
8144
|
configKey: "setupCommand",
|
|
6663
8145
|
envVar: "RUNCASTLE_SETUP_COMMAND",
|
|
8146
|
+
projectColumn: "setupCommand",
|
|
6664
8147
|
restartRequired: false,
|
|
6665
|
-
valueSchema:
|
|
8148
|
+
valueSchema: z10.string().min(1),
|
|
6666
8149
|
parseEnv: idEnv
|
|
6667
8150
|
},
|
|
6668
8151
|
{
|
|
6669
8152
|
key: "verifyCommands",
|
|
6670
8153
|
configKey: "verifyCommands",
|
|
6671
8154
|
envVar: "RUNCASTLE_VERIFY_COMMANDS",
|
|
8155
|
+
projectColumn: "verifyCommands",
|
|
6672
8156
|
restartRequired: false,
|
|
6673
|
-
valueSchema:
|
|
8157
|
+
valueSchema: z10.string().min(1),
|
|
6674
8158
|
parseEnv: idEnv
|
|
6675
8159
|
},
|
|
6676
8160
|
{
|
|
6677
8161
|
key: "knownFailures",
|
|
6678
8162
|
configKey: "knownFailures",
|
|
6679
8163
|
envVar: "RUNCASTLE_KNOWN_FAILURES",
|
|
8164
|
+
projectColumn: "knownFailures",
|
|
6680
8165
|
restartRequired: false,
|
|
6681
|
-
valueSchema:
|
|
8166
|
+
valueSchema: z10.string().min(1),
|
|
6682
8167
|
parseEnv: idEnv
|
|
6683
8168
|
},
|
|
6684
8169
|
{
|
|
@@ -6686,14 +8171,42 @@ var init_settings = __esm(() => {
|
|
|
6686
8171
|
configKey: "mainBranch",
|
|
6687
8172
|
envVar: "RUNCASTLE_MAIN_BRANCH",
|
|
6688
8173
|
restartRequired: false,
|
|
6689
|
-
valueSchema:
|
|
8174
|
+
valueSchema: z10.string().min(1),
|
|
6690
8175
|
parseEnv: idEnv
|
|
6691
8176
|
},
|
|
6692
8177
|
{
|
|
6693
8178
|
key: "devCommand",
|
|
6694
8179
|
projectColumn: "devCommand",
|
|
6695
8180
|
restartRequired: false,
|
|
6696
|
-
valueSchema:
|
|
8181
|
+
valueSchema: z10.string().min(1),
|
|
8182
|
+
parseEnv: idEnv
|
|
8183
|
+
},
|
|
8184
|
+
{
|
|
8185
|
+
key: "dbResetCommand",
|
|
8186
|
+
projectColumn: "dbResetCommand",
|
|
8187
|
+
restartRequired: false,
|
|
8188
|
+
valueSchema: z10.string().min(1),
|
|
8189
|
+
parseEnv: idEnv
|
|
8190
|
+
},
|
|
8191
|
+
{
|
|
8192
|
+
key: "driveSetupCommand",
|
|
8193
|
+
projectColumn: "driveSetupCommand",
|
|
8194
|
+
restartRequired: false,
|
|
8195
|
+
valueSchema: z10.string().min(1),
|
|
8196
|
+
parseEnv: idEnv
|
|
8197
|
+
},
|
|
8198
|
+
{
|
|
8199
|
+
key: "driveStopCommand",
|
|
8200
|
+
projectColumn: "driveStopCommand",
|
|
8201
|
+
restartRequired: false,
|
|
8202
|
+
valueSchema: z10.string().min(1),
|
|
8203
|
+
parseEnv: idEnv
|
|
8204
|
+
},
|
|
8205
|
+
{
|
|
8206
|
+
key: "driveEnv",
|
|
8207
|
+
projectColumn: "driveEnv",
|
|
8208
|
+
restartRequired: false,
|
|
8209
|
+
valueSchema: z10.string().min(1),
|
|
6697
8210
|
parseEnv: idEnv
|
|
6698
8211
|
}
|
|
6699
8212
|
];
|
|
@@ -6702,14 +8215,14 @@ var init_settings = __esm(() => {
|
|
|
6702
8215
|
});
|
|
6703
8216
|
|
|
6704
8217
|
// src/trpc/routers/settings.ts
|
|
6705
|
-
import * as
|
|
8218
|
+
import * as z11 from "zod";
|
|
6706
8219
|
var settingsRouter;
|
|
6707
8220
|
var init_settings2 = __esm(() => {
|
|
6708
8221
|
init_src();
|
|
6709
8222
|
init_settings();
|
|
6710
8223
|
init_context();
|
|
6711
8224
|
settingsRouter = router({
|
|
6712
|
-
get: publicProcedure.input(
|
|
8225
|
+
get: publicProcedure.input(z11.object({ projectId: z11.string().optional() }).optional()).query(({ ctx, input }) => getSettings(ctx, input?.projectId)),
|
|
6713
8226
|
update: publicProcedure.input(SettingsUpdateInput).mutation(({ ctx, input }) => updateSettings(ctx, input))
|
|
6714
8227
|
});
|
|
6715
8228
|
});
|
|
@@ -6876,13 +8389,13 @@ var init_doctor = __esm(() => {
|
|
|
6876
8389
|
});
|
|
6877
8390
|
|
|
6878
8391
|
// src/doctor/system-exec.ts
|
|
6879
|
-
import { spawn as
|
|
8392
|
+
import { spawn as spawn3 } from "child_process";
|
|
6880
8393
|
function createSystemExec(opts = {}) {
|
|
6881
|
-
return (command, args) => new Promise((
|
|
8394
|
+
return (command, args) => new Promise((resolve10) => {
|
|
6882
8395
|
const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
|
|
6883
8396
|
let stdout = "";
|
|
6884
8397
|
let stderr = "";
|
|
6885
|
-
const child =
|
|
8398
|
+
const child = spawn3(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
|
|
6886
8399
|
child.stdout?.on("data", (d) => {
|
|
6887
8400
|
stdout += d.toString();
|
|
6888
8401
|
});
|
|
@@ -6890,10 +8403,10 @@ function createSystemExec(opts = {}) {
|
|
|
6890
8403
|
stderr += d.toString();
|
|
6891
8404
|
});
|
|
6892
8405
|
child.on("error", (err) => {
|
|
6893
|
-
|
|
8406
|
+
resolve10({ ok: false, code: null, stdout, stderr: stderr || String(err) });
|
|
6894
8407
|
});
|
|
6895
8408
|
child.on("close", (code) => {
|
|
6896
|
-
|
|
8409
|
+
resolve10({ ok: true, code: code ?? 0, stdout, stderr });
|
|
6897
8410
|
});
|
|
6898
8411
|
});
|
|
6899
8412
|
}
|
|
@@ -6902,9 +8415,9 @@ var init_system_exec = __esm(() => {
|
|
|
6902
8415
|
});
|
|
6903
8416
|
|
|
6904
8417
|
// src/services/setup.ts
|
|
6905
|
-
import { copyFileSync, existsSync as
|
|
6906
|
-
import { dirname as
|
|
6907
|
-
import { fileURLToPath as
|
|
8418
|
+
import { copyFileSync, existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync4 } from "fs";
|
|
8419
|
+
import { dirname as dirname9, join as join17 } from "path";
|
|
8420
|
+
import { fileURLToPath as fileURLToPath7 } from "url";
|
|
6908
8421
|
async function writeGitIdentity(exec, identity) {
|
|
6909
8422
|
const name = identity.name.trim();
|
|
6910
8423
|
const email = identity.email.trim();
|
|
@@ -6979,16 +8492,16 @@ async function resolveRuntime(exec, preferred) {
|
|
|
6979
8492
|
function resolveSandcastleBin() {
|
|
6980
8493
|
try {
|
|
6981
8494
|
const mainUrl = import.meta.resolve("@ai-hero/sandcastle");
|
|
6982
|
-
let dir =
|
|
8495
|
+
let dir = dirname9(fileURLToPath7(mainUrl));
|
|
6983
8496
|
for (let hops = 0;hops < 6; hops++) {
|
|
6984
|
-
const manifestPath =
|
|
6985
|
-
if (
|
|
6986
|
-
const manifest = JSON.parse(
|
|
8497
|
+
const manifestPath = join17(dir, "package.json");
|
|
8498
|
+
if (existsSync18(manifestPath)) {
|
|
8499
|
+
const manifest = JSON.parse(readFileSync8(manifestPath, "utf8"));
|
|
6987
8500
|
if (manifest.name === "@ai-hero/sandcastle" && manifest.bin?.sandcastle) {
|
|
6988
|
-
return
|
|
8501
|
+
return join17(dir, manifest.bin.sandcastle);
|
|
6989
8502
|
}
|
|
6990
8503
|
}
|
|
6991
|
-
const parent =
|
|
8504
|
+
const parent = dirname9(dir);
|
|
6992
8505
|
if (parent === dir)
|
|
6993
8506
|
break;
|
|
6994
8507
|
dir = parent;
|
|
@@ -7010,21 +8523,21 @@ function terminalSpec(kind, opts) {
|
|
|
7010
8523
|
};
|
|
7011
8524
|
}
|
|
7012
8525
|
function sandcastleTemplateDir() {
|
|
7013
|
-
return resolveAsset(ASSET_ENV.sandcastleTemplate,
|
|
8526
|
+
return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath7(new URL("../assets/sandcastle", import.meta.url)));
|
|
7014
8527
|
}
|
|
7015
8528
|
function scaffoldSandcastleConfig(templateDir, targetDir) {
|
|
7016
|
-
const dir =
|
|
7017
|
-
if (
|
|
8529
|
+
const dir = join17(targetDir, ".sandcastle");
|
|
8530
|
+
if (existsSync18(dir))
|
|
7018
8531
|
return { scaffolded: false, dir };
|
|
7019
|
-
|
|
8532
|
+
mkdirSync9(dir, { recursive: true });
|
|
7020
8533
|
for (const name of readdirSync6(templateDir)) {
|
|
7021
|
-
copyFileSync(
|
|
8534
|
+
copyFileSync(join17(templateDir, name), join17(dir, name));
|
|
7022
8535
|
}
|
|
7023
8536
|
return { scaffolded: true, dir };
|
|
7024
8537
|
}
|
|
7025
8538
|
function prepareSandboxBuildContext() {
|
|
7026
8539
|
const target = sandboxBuildDir();
|
|
7027
|
-
|
|
8540
|
+
mkdirSync9(target, { recursive: true });
|
|
7028
8541
|
scaffoldSandcastleConfig(sandcastleTemplateDir(), target);
|
|
7029
8542
|
return target;
|
|
7030
8543
|
}
|
|
@@ -7060,9 +8573,9 @@ function createTokenVerifier(exec) {
|
|
|
7060
8573
|
function fileAfkTokenIo(exec) {
|
|
7061
8574
|
const path = envPath();
|
|
7062
8575
|
return {
|
|
7063
|
-
read: () =>
|
|
8576
|
+
read: () => existsSync18(path) ? readFileSync8(path, "utf8") : "",
|
|
7064
8577
|
write: (content) => {
|
|
7065
|
-
|
|
8578
|
+
mkdirSync9(dirname9(path), { recursive: true });
|
|
7066
8579
|
writeFileSync4(path, content, "utf8");
|
|
7067
8580
|
},
|
|
7068
8581
|
verify: createTokenVerifier(exec)
|
|
@@ -7077,7 +8590,7 @@ var init_setup = __esm(() => {
|
|
|
7077
8590
|
});
|
|
7078
8591
|
|
|
7079
8592
|
// src/trpc/routers/setup.ts
|
|
7080
|
-
import * as
|
|
8593
|
+
import * as z12 from "zod";
|
|
7081
8594
|
var setupRouter;
|
|
7082
8595
|
var init_setup2 = __esm(() => {
|
|
7083
8596
|
init_src();
|
|
@@ -7093,9 +8606,9 @@ var init_setup2 = __esm(() => {
|
|
|
7093
8606
|
...ctx.config.sandboxImage ? { imageName: ctx.config.sandboxImage } : {}
|
|
7094
8607
|
})),
|
|
7095
8608
|
runtimeGuide: publicProcedure.query(() => runtimeInstallGuide(process.platform)),
|
|
7096
|
-
gitIdentity: publicProcedure.input(
|
|
7097
|
-
afkToken: publicProcedure.input(
|
|
7098
|
-
startTerminal: publicProcedure.input(
|
|
8609
|
+
gitIdentity: publicProcedure.input(z12.object({ name: z12.string(), email: z12.string() })).mutation(({ input }) => writeGitIdentity(createSystemExec(), input)),
|
|
8610
|
+
afkToken: publicProcedure.input(z12.object({ token: z12.string() })).mutation(({ input }) => saveAfkToken(fileAfkTokenIo(createSystemExec()), input.token)),
|
|
8611
|
+
startTerminal: publicProcedure.input(z12.object({ kind: z12.enum(["setup-token", "build-image"]) })).mutation(async ({ ctx, input }) => {
|
|
7099
8612
|
const exec = createSystemExec();
|
|
7100
8613
|
const preferred = ctx.config.sandbox === "podman" ? "podman" : "docker";
|
|
7101
8614
|
const runtime = await resolveRuntime(exec, preferred);
|
|
@@ -7195,16 +8708,16 @@ var init_update_check = __esm(() => {
|
|
|
7195
8708
|
});
|
|
7196
8709
|
|
|
7197
8710
|
// src/version.ts
|
|
7198
|
-
import { readFileSync as
|
|
7199
|
-
import { dirname as
|
|
7200
|
-
import { fileURLToPath as
|
|
8711
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
8712
|
+
import { dirname as dirname10, join as join18 } from "path";
|
|
8713
|
+
import { fileURLToPath as fileURLToPath8 } from "url";
|
|
7201
8714
|
function runcastleVersion() {
|
|
7202
8715
|
if (cached2)
|
|
7203
8716
|
return cached2;
|
|
7204
|
-
const here =
|
|
7205
|
-
for (const pkgPath of [
|
|
8717
|
+
const here = dirname10(fileURLToPath8(import.meta.url));
|
|
8718
|
+
for (const pkgPath of [join18(here, "..", "package.json"), join18(here, "package.json")]) {
|
|
7206
8719
|
try {
|
|
7207
|
-
const pkg = JSON.parse(
|
|
8720
|
+
const pkg = JSON.parse(readFileSync9(pkgPath, "utf8"));
|
|
7208
8721
|
if (typeof pkg.version === "string") {
|
|
7209
8722
|
cached2 = pkg.version;
|
|
7210
8723
|
return cached2;
|
|
@@ -7230,7 +8743,7 @@ var init_system = __esm(() => {
|
|
|
7230
8743
|
});
|
|
7231
8744
|
|
|
7232
8745
|
// src/trpc/routers/ticket.ts
|
|
7233
|
-
import * as
|
|
8746
|
+
import * as z13 from "zod";
|
|
7234
8747
|
var ticketRouter;
|
|
7235
8748
|
var init_ticket = __esm(() => {
|
|
7236
8749
|
init_features();
|
|
@@ -7239,8 +8752,8 @@ var init_ticket = __esm(() => {
|
|
|
7239
8752
|
init_ticket_burner();
|
|
7240
8753
|
init_context();
|
|
7241
8754
|
ticketRouter = router({
|
|
7242
|
-
retry: publicProcedure.input(
|
|
7243
|
-
stop: publicProcedure.input(
|
|
8755
|
+
retry: publicProcedure.input(z13.object({ ticketId: z13.string(), fresh: z13.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
|
|
8756
|
+
stop: publicProcedure.input(z13.object({ ticketId: z13.string() })).mutation(({ ctx, input }) => {
|
|
7244
8757
|
if (stopTicketRun(input.ticketId))
|
|
7245
8758
|
return { stopped: true, swept: false };
|
|
7246
8759
|
const ticket = getTicket(ctx, input.ticketId);
|
|
@@ -7250,7 +8763,7 @@ var init_ticket = __esm(() => {
|
|
|
7250
8763
|
}
|
|
7251
8764
|
return { stopped: false, swept: false };
|
|
7252
8765
|
}),
|
|
7253
|
-
cancel: publicProcedure.input(
|
|
8766
|
+
cancel: publicProcedure.input(z13.object({ ticketId: z13.string(), reason: z13.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason))
|
|
7254
8767
|
});
|
|
7255
8768
|
});
|
|
7256
8769
|
|
|
@@ -7281,18 +8794,18 @@ var init_router = __esm(() => {
|
|
|
7281
8794
|
});
|
|
7282
8795
|
|
|
7283
8796
|
// src/workflows/reconcile-runs.ts
|
|
7284
|
-
import { eq as
|
|
8797
|
+
import { eq as eq13 } from "drizzle-orm";
|
|
7285
8798
|
async function reconcileStaleRuns(ctx) {
|
|
7286
|
-
const stale = ctx.db.select().from(runs).where(
|
|
8799
|
+
const stale = ctx.db.select().from(runs).where(eq13(runs.status, "running")).all().map(rowToRun);
|
|
7287
8800
|
const reconciled = [];
|
|
7288
|
-
for (const
|
|
7289
|
-
if (isRunActive(
|
|
8801
|
+
for (const run4 of stale) {
|
|
8802
|
+
if (isRunActive(run4.id))
|
|
7290
8803
|
continue;
|
|
7291
|
-
ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(
|
|
7292
|
-
const released = releaseForSession(ctx,
|
|
7293
|
-
const swept = workflowClaimsFeatureBranch(
|
|
7294
|
-
if (workflowClaimsFeatureBranch(
|
|
7295
|
-
const feature = tryGetFeature(ctx,
|
|
8804
|
+
ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq13(runs.id, run4.id)).run();
|
|
8805
|
+
const released = releaseForSession(ctx, run4.id);
|
|
8806
|
+
const swept = workflowClaimsFeatureBranch(run4.workflow) ? sweepOrphanedBurning(ctx, run4.featureId, "orphaned by server restart \u2014 retry to resume its commits") : [];
|
|
8807
|
+
if (workflowClaimsFeatureBranch(run4.workflow)) {
|
|
8808
|
+
const feature = tryGetFeature(ctx, run4.featureId);
|
|
7296
8809
|
const project = feature ? getProjectById(ctx, feature.projectId) : null;
|
|
7297
8810
|
if (feature && project) {
|
|
7298
8811
|
try {
|
|
@@ -7300,18 +8813,18 @@ async function reconcileStaleRuns(ctx) {
|
|
|
7300
8813
|
} catch {}
|
|
7301
8814
|
}
|
|
7302
8815
|
}
|
|
7303
|
-
emit(ctx,
|
|
8816
|
+
emit(ctx, run4.featureId, {
|
|
7304
8817
|
type: "run.reconciled",
|
|
7305
|
-
message: `run marked failed at boot \u2014 the server restarted while it was running (${
|
|
7306
|
-
runId:
|
|
8818
|
+
message: `run marked failed at boot \u2014 the server restarted while it was running (${run4.workflow})`,
|
|
8819
|
+
runId: run4.id,
|
|
7307
8820
|
data: {
|
|
7308
|
-
runId:
|
|
7309
|
-
workflow:
|
|
8821
|
+
runId: run4.id,
|
|
8822
|
+
workflow: run4.workflow,
|
|
7310
8823
|
releasedWaypointIds: released.map((w) => w.id),
|
|
7311
8824
|
sweptTicketSeqs: swept.map((t2) => t2.seq)
|
|
7312
8825
|
}
|
|
7313
8826
|
});
|
|
7314
|
-
reconciled.push(
|
|
8827
|
+
reconciled.push(run4);
|
|
7315
8828
|
}
|
|
7316
8829
|
for (const project of allProjects(ctx)) {
|
|
7317
8830
|
try {
|
|
@@ -7342,7 +8855,7 @@ import { Hono as Hono4 } from "hono";
|
|
|
7342
8855
|
function buildApp(ctx) {
|
|
7343
8856
|
const app = new Hono4;
|
|
7344
8857
|
setRuntimeCtx(ctx);
|
|
7345
|
-
app.get("/health", (c) => c.json({ ok: true }));
|
|
8858
|
+
app.get("/health", (c) => c.json({ ok: true, dataDir: dataDir() }));
|
|
7346
8859
|
app.use("/api/trpc/*", async (c, next) => {
|
|
7347
8860
|
await next();
|
|
7348
8861
|
if (c.res.headers.get("content-type") === "application/json") {
|
|
@@ -7374,6 +8887,10 @@ async function startServer() {
|
|
|
7374
8887
|
if (staleRuns.length > 0) {
|
|
7375
8888
|
console.log(`reconciled ${staleRuns.length} stale run(s) from a previous server run`);
|
|
7376
8889
|
}
|
|
8890
|
+
const stalePreps = reconcilePreps(ctx);
|
|
8891
|
+
if (stalePreps > 0) {
|
|
8892
|
+
console.log(`reconciled ${stalePreps} stale preparation run(s) from a previous server run`);
|
|
8893
|
+
}
|
|
7377
8894
|
const server = Bun.serve({
|
|
7378
8895
|
port: config2.serverPort,
|
|
7379
8896
|
fetch(req, srv) {
|
|
@@ -7383,7 +8900,7 @@ async function startServer() {
|
|
|
7383
8900
|
},
|
|
7384
8901
|
websocket: terminalWebSocket
|
|
7385
8902
|
});
|
|
7386
|
-
console.log(`runcastle server listening on http://localhost:${config2.serverPort}`);
|
|
8903
|
+
console.log(`runcastle${process.env.RUNCASTLE_DEV ? " [dev]" : ""} server listening on ` + `http://localhost:${config2.serverPort} \u2014 data dir: ${dataDir()}`);
|
|
7387
8904
|
const SHUTDOWN_KEY = Symbol.for("runcastle.shutdown.wired");
|
|
7388
8905
|
const g = globalThis;
|
|
7389
8906
|
if (!g[SHUTDOWN_KEY]) {
|
|
@@ -7403,6 +8920,7 @@ var init_src2 = __esm(() => {
|
|
|
7403
8920
|
init_client();
|
|
7404
8921
|
init_migrate();
|
|
7405
8922
|
init_reconcile();
|
|
8923
|
+
init_prep();
|
|
7406
8924
|
init_server();
|
|
7407
8925
|
init_registry();
|
|
7408
8926
|
init_ws();
|