@tablation/crew 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +264 -172
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -7,10 +7,157 @@ import { hostname as hostname2, homedir as homedir5 } from "os";
7
7
  import { spawnSync as spawnSync3 } from "child_process";
8
8
 
9
9
  // src/config.ts
10
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
11
- import { resolve, dirname, isAbsolute, join as join2, basename as basename2 } from "path";
10
+ import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
11
+ import { resolve, dirname as dirname2, isAbsolute, join as join3, basename as basename2 } from "path";
12
12
  import { homedir, tmpdir } from "os";
13
13
  import { parse as parse2 } from "yaml";
14
+
15
+ // src/events.ts
16
+ import {
17
+ appendFileSync,
18
+ mkdirSync,
19
+ existsSync,
20
+ statSync,
21
+ renameSync,
22
+ openSync,
23
+ closeSync,
24
+ rmSync
25
+ } from "fs";
26
+ import { join, dirname } from "path";
27
+ function render(e) {
28
+ const scope = e.role ? `${e.step}[${e.role}]` : e.step;
29
+ const ticket = e.ticket ? ` ${e.ticket}` : "";
30
+ const level = e.level === "info" ? "" : `${e.level.toUpperCase()}: `;
31
+ return `${e.at} ${scope}:${ticket} ${level}${e.message}`;
32
+ }
33
+ var DEFAULT_EVENTS_ROTATE_BYTES = 64 * 1024 * 1024;
34
+ var ROTATED_EVENTS_RE = /^events\.\d{8}T\d{6}Z(?:-\d+)?\.jsonl$/;
35
+ var ROTATE_LOCK_STALE_MS = 6e4;
36
+ function rotateEventsFile(file, maxBytes, now = /* @__PURE__ */ new Date()) {
37
+ try {
38
+ if (!existsSync(file) || statSync(file).size <= maxBytes) return void 0;
39
+ } catch {
40
+ return void 0;
41
+ }
42
+ const lock = `${file}.rotate.lock`;
43
+ try {
44
+ try {
45
+ closeSync(openSync(lock, "wx"));
46
+ } catch {
47
+ try {
48
+ if (Date.now() - statSync(lock).mtimeMs < ROTATE_LOCK_STALE_MS) return void 0;
49
+ rmSync(lock, { force: true });
50
+ closeSync(openSync(lock, "wx"));
51
+ } catch {
52
+ return void 0;
53
+ }
54
+ }
55
+ try {
56
+ if (statSync(file).size <= maxBytes) return void 0;
57
+ const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
58
+ let dest = join(dirname(file), `events.${stamp}.jsonl`);
59
+ for (let n = 1; existsSync(dest); n++) dest = join(dirname(file), `events.${stamp}-${n}.jsonl`);
60
+ renameSync(file, dest);
61
+ return dest;
62
+ } finally {
63
+ rmSync(lock, { force: true });
64
+ }
65
+ } catch {
66
+ return void 0;
67
+ }
68
+ }
69
+ var Emitter = class _Emitter {
70
+ cycle;
71
+ opts;
72
+ now;
73
+ step = "poll";
74
+ role;
75
+ constructor(opts) {
76
+ this.opts = opts;
77
+ this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
78
+ this.cycle = opts.cycleId ?? this.now().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
79
+ for (const f of [opts.eventFile, opts.logFile]) {
80
+ if (!f) continue;
81
+ try {
82
+ mkdirSync(dirname(f), { recursive: true });
83
+ } catch {
84
+ }
85
+ }
86
+ }
87
+ /** Everything emitted after this belongs to `step`, until the next one. */
88
+ enter(step, role) {
89
+ this.step = step;
90
+ this.role = role;
91
+ }
92
+ emit(message, extra = {}) {
93
+ const e = {
94
+ at: this.now().toISOString(),
95
+ cycle: this.cycle,
96
+ route: this.opts.route,
97
+ step: extra.step ?? this.step,
98
+ level: extra.level ?? "info",
99
+ message,
100
+ ...extra.role ?? this.role ? { role: extra.role ?? this.role } : {},
101
+ ...extra.ticket ? { ticket: extra.ticket } : {},
102
+ ...extra.data ? { data: extra.data } : {}
103
+ };
104
+ const line = render(e);
105
+ try {
106
+ if (this.opts.eventFile && this.opts.rotateBytes) rotateEventsFile(this.opts.eventFile, this.opts.rotateBytes, this.now());
107
+ } catch {
108
+ }
109
+ try {
110
+ if (this.opts.eventFile) appendFileSync(this.opts.eventFile, `${JSON.stringify(e)}
111
+ `);
112
+ } catch {
113
+ }
114
+ try {
115
+ if (this.opts.logFile) appendFileSync(this.opts.logFile, `${line}
116
+ `);
117
+ } catch {
118
+ }
119
+ try {
120
+ (this.opts.console ?? ((l) => process.stderr.write(`${l}
121
+ `)))(line);
122
+ } catch {
123
+ }
124
+ return e;
125
+ }
126
+ warn(message, extra = {}) {
127
+ return this.emit(message, { ...extra, level: "warn" });
128
+ }
129
+ error(message, extra = {}) {
130
+ return this.emit(message, { ...extra, level: "error" });
131
+ }
132
+ /**
133
+ * A view of this emitter labelling every event with a different route,
134
+ * sharing the same sinks and cycle id. For a fleet-wide run, the top-level
135
+ * Emitter is stamped with just one route (see cli.ts) — a phase that
136
+ * actually knows which route it's working (release, per-repo poll) uses
137
+ * this instead, so its events aren't all attributed to `routes[0]`.
138
+ */
139
+ forRoute(route) {
140
+ return new _Emitter({ ...this.opts, route, cycleId: this.cycle });
141
+ }
142
+ /** Times a step and emits its outcome, so a view can show duration. */
143
+ async timed(step, message, fn, role) {
144
+ this.enter(step, role);
145
+ const started = this.now().getTime();
146
+ try {
147
+ const result = await fn();
148
+ this.emit(message, { data: { ms: this.now().getTime() - started, ok: true } });
149
+ return result;
150
+ } catch (err) {
151
+ this.error(`${message} \u2014 ${err.message}`, {
152
+ data: { ms: this.now().getTime() - started, ok: false }
153
+ });
154
+ throw err;
155
+ }
156
+ }
157
+ };
158
+ var eventFileFor = (stateDir) => join(stateDir, "events.jsonl");
159
+
160
+ // src/config.ts
14
161
  import { getSessionStore, hostFromUrl } from "@tablation/client";
15
162
 
16
163
  // src/platform.ts
@@ -50,8 +197,8 @@ function explain(ship, required) {
50
197
  }
51
198
 
52
199
  // src/repo-config.ts
53
- import { readFileSync, existsSync } from "fs";
54
- import { join, basename } from "path";
200
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
201
+ import { join as join2, basename } from "path";
55
202
  import { parse } from "yaml";
56
203
  var SPEC_VERSION = 1;
57
204
  var REPO_CONFIG_FILENAMES = [".crew.yaml", ".crew.yml", ".crew.json"];
@@ -124,8 +271,8 @@ var MODES = ["local", "integrate", "ci_manual", "ci_auto", "external"];
124
271
  var PROVIDERS = ["github", "buildkite", "other", "none"];
125
272
  function findRepoConfig(dir) {
126
273
  for (const name of REPO_CONFIG_FILENAMES) {
127
- const p = join(dir, name);
128
- if (existsSync(p)) return p;
274
+ const p = join2(dir, name);
275
+ if (existsSync2(p)) return p;
129
276
  }
130
277
  return null;
131
278
  }
@@ -355,7 +502,7 @@ function parseRepoConfig(text, file) {
355
502
  function loadRepoConfig(dir) {
356
503
  const file = findRepoConfig(dir);
357
504
  if (!file) return null;
358
- return parseRepoConfig(readFileSync(file, "utf8"), file);
505
+ return parseRepoConfig(readFileSync2(file, "utf8"), file);
359
506
  }
360
507
  function hookLabel(cfg, hook2) {
361
508
  return cfg.labels[hook2] ?? cfg.hooks[hook2] ?? hook2;
@@ -631,6 +778,7 @@ var SHIP_KEYS = /* @__PURE__ */ new Set([
631
778
  "apiKey",
632
779
  "maxConcurrentAgents",
633
780
  "streamRetentionDays",
781
+ "eventsRotateBytes",
634
782
  "reposBasePath",
635
783
  "relayHost",
636
784
  "relayPort",
@@ -767,11 +915,11 @@ function expand(p, base) {
767
915
  return isAbsolute(out) ? out : resolve(base, out);
768
916
  }
769
917
  function resolvePromptsDir(promptSet, crewHome, base) {
770
- if (!promptSet) return join2(crewHome, "prompts", "default");
918
+ if (!promptSet) return join3(crewHome, "prompts", "default");
771
919
  if (promptSet.includes("/") || promptSet.startsWith(".") || promptSet.startsWith("~")) {
772
920
  return expand(promptSet, base);
773
921
  }
774
- return join2(crewHome, "prompts", promptSet);
922
+ return join3(crewHome, "prompts", promptSet);
775
923
  }
776
924
  var Missing = class {
777
925
  paths = [];
@@ -795,7 +943,7 @@ var Missing = class {
795
943
  };
796
944
  function crewVersion(crewHome) {
797
945
  try {
798
- const pkg = JSON.parse(readFileSync2(join2(crewHome, "package.json"), "utf8"));
946
+ const pkg = JSON.parse(readFileSync3(join3(crewHome, "package.json"), "utf8"));
799
947
  return pkg.version ?? "0.0.0";
800
948
  } catch {
801
949
  return "0.0.0";
@@ -805,26 +953,26 @@ function configSearchPath(crewHome) {
805
953
  if (process.env.CREW_CONFIG) return [resolve(process.env.CREW_CONFIG)];
806
954
  const xdg = process.env.XDG_CONFIG_HOME;
807
955
  return [
808
- ...xdg ? [join2(xdg, "crew", "crew.yaml")] : [],
809
- join2(homedir(), ".config", "crew", "crew.yaml"),
956
+ ...xdg ? [join3(xdg, "crew", "crew.yaml")] : [],
957
+ join3(homedir(), ".config", "crew", "crew.yaml"),
810
958
  resolve(crewHome, "crew.yaml")
811
959
  ];
812
960
  }
813
961
  function findConfigFile(crewHome) {
814
962
  const candidates = configSearchPath(crewHome);
815
- return candidates.find((c) => existsSync2(c)) ?? candidates[candidates.length - 1];
963
+ return candidates.find((c) => existsSync3(c)) ?? candidates[candidates.length - 1];
816
964
  }
817
- var defaultConfigPath = () => join2(process.env.XDG_CONFIG_HOME ?? join2(homedir(), ".config"), "crew", "crew.yaml");
965
+ var defaultConfigPath = () => join3(process.env.XDG_CONFIG_HOME ?? join3(homedir(), ".config"), "crew", "crew.yaml");
818
966
  function routeSlug(route) {
819
967
  return route.replace(/\//g, "-");
820
968
  }
821
969
  function resolvedPathFor(stateDir, route) {
822
970
  const [workspace, project] = splitRoute(route);
823
- return join2(stateDir, "resolved", workspace, `${project}.json`);
971
+ return join3(stateDir, "resolved", workspace, `${project}.json`);
824
972
  }
825
973
  function apiKeyPathFor(stateDir, route) {
826
974
  const [workspace, project] = splitRoute(route);
827
- return join2(stateDir, "keys", workspace, `${project}.env`);
975
+ return join3(stateDir, "keys", workspace, `${project}.env`);
828
976
  }
829
977
  function splitRoute(route) {
830
978
  const parts = route.split("/");
@@ -835,9 +983,9 @@ function splitRoute(route) {
835
983
  }
836
984
  function readResolvedFile(stateDir, route) {
837
985
  const path = resolvedPathFor(stateDir, route);
838
- if (!existsSync2(path)) return void 0;
986
+ if (!existsSync3(path)) return void 0;
839
987
  try {
840
- return JSON.parse(readFileSync2(path, "utf8"));
988
+ return JSON.parse(readFileSync3(path, "utf8"));
841
989
  } catch (e) {
842
990
  throw new ConfigError(`${path}: not valid JSON \u2014 ${e.message}`);
843
991
  }
@@ -893,23 +1041,23 @@ function parseResolved(raw, m, where) {
893
1041
  }
894
1042
  function loadConfig(crewHome, configFile, opts = {}) {
895
1043
  const file = configFile ?? findConfigFile(crewHome);
896
- if (opts.text === void 0 && !existsSync2(file)) {
1044
+ if (opts.text === void 0 && !existsSync3(file)) {
897
1045
  throw new ConfigError(
898
1046
  `no config found. Looked in:
899
1047
  ` + configSearchPath(crewHome).map((c) => ` - ${c}`).join("\n") + `
900
1048
 
901
1049
  Create the first of those:
902
- mkdir -p ${dirname(defaultConfigPath())} && cp ${join2(crewHome, "crew.example.yaml")} ${defaultConfigPath()}`
1050
+ mkdir -p ${dirname2(defaultConfigPath())} && cp ${join3(crewHome, "crew.example.yaml")} ${defaultConfigPath()}`
903
1051
  );
904
1052
  }
905
1053
  let raw;
906
1054
  try {
907
- raw = parse2(opts.text ?? readFileSync2(file, "utf8"));
1055
+ raw = parse2(opts.text ?? readFileSync3(file, "utf8"));
908
1056
  } catch (e) {
909
1057
  throw new ConfigError(`${file}: not valid YAML \u2014 ${e.message}`);
910
1058
  }
911
1059
  if (!raw || typeof raw !== "object") throw new ConfigError(`${file} is empty or not a mapping`);
912
- const base = dirname(file);
1060
+ const base = dirname2(file);
913
1061
  const missing = new Missing();
914
1062
  rejectUnknownKeys(raw, /* @__PURE__ */ new Set(["ship", "routes"]), "top-level", file);
915
1063
  const shipRaw = raw.ship ?? {};
@@ -926,6 +1074,10 @@ Create the first of those:
926
1074
  if (!Number.isInteger(streamRetentionDays) || streamRetentionDays < 1) {
927
1075
  missing.add(`ship.streamRetentionDays must be a positive integer (got "${shipRaw.streamRetentionDays}")`);
928
1076
  }
1077
+ const eventsRotateBytes = shipRaw.eventsRotateBytes === void 0 ? DEFAULT_EVENTS_ROTATE_BYTES : Number(shipRaw.eventsRotateBytes);
1078
+ if (!Number.isInteger(eventsRotateBytes) || eventsRotateBytes < 1) {
1079
+ missing.add(`ship.eventsRotateBytes must be a positive integer (got "${shipRaw.eventsRotateBytes}")`);
1080
+ }
929
1081
  const maxThinkingTokens = shipRaw.agent?.maxThinkingTokens === void 0 ? DEFAULT_MAX_THINKING_TOKENS : Number(shipRaw.agent.maxThinkingTokens);
930
1082
  if (!Number.isInteger(maxThinkingTokens) || maxThinkingTokens < 0) {
931
1083
  missing.add(`ship.agent.maxThinkingTokens must be a non-negative integer (got "${shipRaw.agent?.maxThinkingTokens}")`);
@@ -968,10 +1120,11 @@ Create the first of those:
968
1120
  useNvm: shipRaw.useNvm !== false,
969
1121
  nvmSh: shipRaw.nvmSh,
970
1122
  stateDir,
971
- logFile: expand(shipRaw.logFile ?? join2(tmpdir(), "crew.log"), base),
1123
+ logFile: expand(shipRaw.logFile ?? join3(tmpdir(), "crew.log"), base),
972
1124
  userAgent: shipRaw.userAgent ?? `Mozilla/5.0 CrewAgent/${crewVersion(crewHome)}`,
973
1125
  maxConcurrentAgents,
974
1126
  streamRetentionDays,
1127
+ eventsRotateBytes,
975
1128
  relayHost: shipRaw.relayHost ? String(shipRaw.relayHost) : void 0,
976
1129
  // 2222 matches the relay's own RELAY_SSH_PORT default (crew-relay README).
977
1130
  relayPort: shipRaw.relayPort !== void 0 ? Number(shipRaw.relayPort) : 2222,
@@ -1102,8 +1255,8 @@ function repoIdForName(r, name) {
1102
1255
  }
1103
1256
  function resolveApiKey(r) {
1104
1257
  if (r.apiKey) return r.apiKey;
1105
- if (r.apiKeyFile && r.apiKeyVar && existsSync2(r.apiKeyFile)) {
1106
- for (const line of readFileSync2(r.apiKeyFile, "utf8").split("\n")) {
1258
+ if (r.apiKeyFile && r.apiKeyVar && existsSync3(r.apiKeyFile)) {
1259
+ for (const line of readFileSync3(r.apiKeyFile, "utf8").split("\n")) {
1107
1260
  const m = line.match(new RegExp(`^${r.apiKeyVar}=(.*)$`));
1108
1261
  if (m) return (m[1] ?? "").trim().replace(/^["']|["']$/g, "");
1109
1262
  }
@@ -1166,7 +1319,7 @@ function configuredMembers(rt) {
1166
1319
  return out;
1167
1320
  }
1168
1321
  function defaultRepoDir(basePath, workspace, repoName) {
1169
- return join2(basePath, workspace, repoName);
1322
+ return join3(basePath, workspace, repoName);
1170
1323
  }
1171
1324
  function reposOf(r) {
1172
1325
  const named = Object.entries(r.repos);
@@ -1190,7 +1343,7 @@ function passengerRepoTargets(r) {
1190
1343
  return reposOf(r).filter((t) => r.repoOverrides[t.name]?.hostPassengers !== false);
1191
1344
  }
1192
1345
  function passengerCheckoutDir(basePath, workspace, repoName) {
1193
- return join2(basePath, workspace, ".passenger-checkouts", repoName);
1346
+ return join3(basePath, workspace, ".passenger-checkouts", repoName);
1194
1347
  }
1195
1348
  function passengerCheckoutTargets(r) {
1196
1349
  if (!r.hostPassengers) return [];
@@ -1322,17 +1475,17 @@ function rosterMarkdown(roster, meId) {
1322
1475
  }
1323
1476
 
1324
1477
  // src/state.ts
1325
- import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync3, mkdirSync, rmSync, statSync } from "fs";
1326
- import { join as join3 } from "path";
1478
+ import { readFileSync as readFileSync4, writeFileSync, existsSync as existsSync4, mkdirSync as mkdirSync2, rmSync as rmSync2, statSync as statSync2 } from "fs";
1479
+ import { join as join4 } from "path";
1327
1480
  var EPOCH = "1970-01-01T00:00:00.000Z";
1328
1481
  var State = class _State {
1329
1482
  dir;
1330
1483
  constructor(dir) {
1331
1484
  this.dir = dir;
1332
- mkdirSync(dir, { recursive: true });
1485
+ mkdirSync2(dir, { recursive: true });
1333
1486
  }
1334
1487
  path(...parts) {
1335
- return join3(this.dir, ...parts);
1488
+ return join4(this.dir, ...parts);
1336
1489
  }
1337
1490
  /**
1338
1491
  * The timestamp every "new since last poll" check is measured against.
@@ -1343,7 +1496,7 @@ var State = class _State {
1343
1496
  */
1344
1497
  watermark() {
1345
1498
  try {
1346
- const v = readFileSync3(this.path(".poll-watermark"), "utf8").trim();
1499
+ const v = readFileSync4(this.path(".poll-watermark"), "utf8").trim();
1347
1500
  return v || EPOCH;
1348
1501
  } catch {
1349
1502
  return EPOCH;
@@ -1368,10 +1521,10 @@ var State = class _State {
1368
1521
  }
1369
1522
  /** Whole-crew pause, and the per-role sentinels one level down. */
1370
1523
  isPaused() {
1371
- return existsSync3(this.path(".crew-paused"));
1524
+ return existsSync4(this.path(".crew-paused"));
1372
1525
  }
1373
1526
  isRolePaused(role) {
1374
- return existsSync3(this.path(`.role-paused-${role}`));
1527
+ return existsSync4(this.path(`.role-paused-${role}`));
1375
1528
  }
1376
1529
  pausedRoles(roles) {
1377
1530
  return new Set(roles.filter((r) => this.isRolePaused(r)));
@@ -1380,7 +1533,7 @@ var State = class _State {
1380
1533
  writeFileSync(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), "");
1381
1534
  }
1382
1535
  resume(role) {
1383
- rmSync(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), { force: true });
1536
+ rmSync2(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), { force: true });
1384
1537
  }
1385
1538
  /**
1386
1539
  * The release phase (merge, deploy, release) has its own sentinel, one
@@ -1388,11 +1541,11 @@ var State = class _State {
1388
1541
  * while agents keep working.
1389
1542
  */
1390
1543
  isReleasePaused() {
1391
- return existsSync3(this.path(".release-paused"));
1544
+ return existsSync4(this.path(".release-paused"));
1392
1545
  }
1393
1546
  setReleasePaused(paused) {
1394
1547
  if (paused) writeFileSync(this.path(".release-paused"), "");
1395
- else rmSync(this.path(".release-paused"), { force: true });
1548
+ else rmSync2(this.path(".release-paused"), { force: true });
1396
1549
  }
1397
1550
  /**
1398
1551
  * Why a release cycle must not run right now, or null when it may. Every
@@ -1429,7 +1582,7 @@ var State = class _State {
1429
1582
  const name = _State.safe(rawName);
1430
1583
  const file = this.path(`.${name}.lock`);
1431
1584
  try {
1432
- const existing = Number.parseInt(readFileSync3(file, "utf8").trim(), 10);
1585
+ const existing = Number.parseInt(readFileSync4(file, "utf8").trim(), 10);
1433
1586
  if (Number.isFinite(existing) && existing > 0) {
1434
1587
  try {
1435
1588
  process.kill(existing, 0);
@@ -1445,7 +1598,7 @@ var State = class _State {
1445
1598
  ok: true,
1446
1599
  release: () => {
1447
1600
  try {
1448
- rmSync(file, { force: true });
1601
+ rmSync2(file, { force: true });
1449
1602
  } catch {
1450
1603
  }
1451
1604
  }
@@ -1523,7 +1676,7 @@ var State = class _State {
1523
1676
  const heldReported = `.release-held-${suffix}`;
1524
1677
  const read = (f) => {
1525
1678
  try {
1526
- return readFileSync3(this.path(f), "utf8").trim() || null;
1679
+ return readFileSync4(this.path(f), "utf8").trim() || null;
1527
1680
  } catch {
1528
1681
  return null;
1529
1682
  }
@@ -1537,7 +1690,7 @@ var State = class _State {
1537
1690
  return n;
1538
1691
  },
1539
1692
  clearBlocked: () => {
1540
- rmSync(this.path(blocked), { force: true });
1693
+ rmSync2(this.path(blocked), { force: true });
1541
1694
  },
1542
1695
  /**
1543
1696
  * The commit a deploy last failed on.
@@ -1553,7 +1706,7 @@ var State = class _State {
1553
1706
  `);
1554
1707
  },
1555
1708
  clearDeployFailed: () => {
1556
- rmSync(this.path(failed), { force: true });
1709
+ rmSync2(this.path(failed), { force: true });
1557
1710
  },
1558
1711
  /**
1559
1712
  * Consecutive test-gate failures on the same head (CREW-1368). A failure
@@ -1568,12 +1721,12 @@ var State = class _State {
1568
1721
  return next;
1569
1722
  },
1570
1723
  clearTestGateFailed: () => {
1571
- rmSync(this.path(gateFailed), { force: true });
1724
+ rmSync2(this.path(gateFailed), { force: true });
1572
1725
  },
1573
1726
  /** Whether the board was already told about this head's red gate. */
1574
1727
  heldReported: () => {
1575
1728
  try {
1576
- return readFileSync3(this.path(heldReported), "utf8").replace(/\n$/, "") || null;
1729
+ return readFileSync4(this.path(heldReported), "utf8").replace(/\n$/, "") || null;
1577
1730
  } catch {
1578
1731
  return null;
1579
1732
  }
@@ -1583,7 +1736,7 @@ var State = class _State {
1583
1736
  `);
1584
1737
  },
1585
1738
  clearHeldReported: () => {
1586
- rmSync(this.path(heldReported), { force: true });
1739
+ rmSync2(this.path(heldReported), { force: true });
1587
1740
  },
1588
1741
  testGateReported: (sha) => read(gateReported) === sha,
1589
1742
  noteTestGateReported: (sha) => {
@@ -1610,13 +1763,13 @@ var State = class _State {
1610
1763
  const ticketStreakFile = `.fairness-ticket-streak-${suffix}`;
1611
1764
  const read = (f) => {
1612
1765
  try {
1613
- return readFileSync3(this.path(f), "utf8").trim() || null;
1766
+ return readFileSync4(this.path(f), "utf8").trim() || null;
1614
1767
  } catch {
1615
1768
  return null;
1616
1769
  }
1617
1770
  };
1618
1771
  const clear2 = () => {
1619
- for (const f of [streakFile, ticketFile, sinceFile, ticketStreakFile]) rmSync(this.path(f), { force: true });
1772
+ for (const f of [streakFile, ticketFile, sinceFile, ticketStreakFile]) rmSync2(this.path(f), { force: true });
1620
1773
  };
1621
1774
  return {
1622
1775
  /** Consecutive cycles this route had actionable work and did not win. */
@@ -1675,7 +1828,7 @@ var State = class _State {
1675
1828
  return {
1676
1829
  previous: () => {
1677
1830
  try {
1678
- const parsed = JSON.parse(readFileSync3(this.path(file), "utf8"));
1831
+ const parsed = JSON.parse(readFileSync4(this.path(file), "utf8"));
1679
1832
  return parsed && typeof parsed === "object" ? parsed : {};
1680
1833
  } catch {
1681
1834
  return {};
@@ -1696,7 +1849,7 @@ var State = class _State {
1696
1849
  return {
1697
1850
  previous: () => {
1698
1851
  try {
1699
- const parsed = JSON.parse(readFileSync3(this.path(file), "utf8"));
1852
+ const parsed = JSON.parse(readFileSync4(this.path(file), "utf8"));
1700
1853
  return Array.isArray(parsed) ? parsed : [];
1701
1854
  } catch {
1702
1855
  return [];
@@ -1720,7 +1873,7 @@ var State = class _State {
1720
1873
  },
1721
1874
  at: () => {
1722
1875
  try {
1723
- return statSync(this.path(file)).mtimeMs;
1876
+ return statSync2(this.path(file)).mtimeMs;
1724
1877
  } catch {
1725
1878
  return null;
1726
1879
  }
@@ -1729,109 +1882,13 @@ var State = class _State {
1729
1882
  }
1730
1883
  readInt(file) {
1731
1884
  try {
1732
- return Number.parseInt(readFileSync3(this.path(file), "utf8").trim(), 10) || 0;
1885
+ return Number.parseInt(readFileSync4(this.path(file), "utf8").trim(), 10) || 0;
1733
1886
  } catch {
1734
1887
  return 0;
1735
1888
  }
1736
1889
  }
1737
1890
  };
1738
1891
 
1739
- // src/events.ts
1740
- import { appendFileSync, mkdirSync as mkdirSync2 } from "fs";
1741
- import { join as join4, dirname as dirname2 } from "path";
1742
- function render(e) {
1743
- const scope = e.role ? `${e.step}[${e.role}]` : e.step;
1744
- const ticket = e.ticket ? ` ${e.ticket}` : "";
1745
- const level = e.level === "info" ? "" : `${e.level.toUpperCase()}: `;
1746
- return `${e.at} ${scope}:${ticket} ${level}${e.message}`;
1747
- }
1748
- var Emitter = class _Emitter {
1749
- cycle;
1750
- opts;
1751
- now;
1752
- step = "poll";
1753
- role;
1754
- constructor(opts) {
1755
- this.opts = opts;
1756
- this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
1757
- this.cycle = opts.cycleId ?? this.now().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
1758
- for (const f of [opts.eventFile, opts.logFile]) {
1759
- if (!f) continue;
1760
- try {
1761
- mkdirSync2(dirname2(f), { recursive: true });
1762
- } catch {
1763
- }
1764
- }
1765
- }
1766
- /** Everything emitted after this belongs to `step`, until the next one. */
1767
- enter(step, role) {
1768
- this.step = step;
1769
- this.role = role;
1770
- }
1771
- emit(message, extra = {}) {
1772
- const e = {
1773
- at: this.now().toISOString(),
1774
- cycle: this.cycle,
1775
- route: this.opts.route,
1776
- step: extra.step ?? this.step,
1777
- level: extra.level ?? "info",
1778
- message,
1779
- ...extra.role ?? this.role ? { role: extra.role ?? this.role } : {},
1780
- ...extra.ticket ? { ticket: extra.ticket } : {},
1781
- ...extra.data ? { data: extra.data } : {}
1782
- };
1783
- const line = render(e);
1784
- try {
1785
- if (this.opts.eventFile) appendFileSync(this.opts.eventFile, `${JSON.stringify(e)}
1786
- `);
1787
- } catch {
1788
- }
1789
- try {
1790
- if (this.opts.logFile) appendFileSync(this.opts.logFile, `${line}
1791
- `);
1792
- } catch {
1793
- }
1794
- try {
1795
- (this.opts.console ?? ((l) => process.stderr.write(`${l}
1796
- `)))(line);
1797
- } catch {
1798
- }
1799
- return e;
1800
- }
1801
- warn(message, extra = {}) {
1802
- return this.emit(message, { ...extra, level: "warn" });
1803
- }
1804
- error(message, extra = {}) {
1805
- return this.emit(message, { ...extra, level: "error" });
1806
- }
1807
- /**
1808
- * A view of this emitter labelling every event with a different route,
1809
- * sharing the same sinks and cycle id. For a fleet-wide run, the top-level
1810
- * Emitter is stamped with just one route (see cli.ts) — a phase that
1811
- * actually knows which route it's working (release, per-repo poll) uses
1812
- * this instead, so its events aren't all attributed to `routes[0]`.
1813
- */
1814
- forRoute(route) {
1815
- return new _Emitter({ ...this.opts, route, cycleId: this.cycle });
1816
- }
1817
- /** Times a step and emits its outcome, so a view can show duration. */
1818
- async timed(step, message, fn, role) {
1819
- this.enter(step, role);
1820
- const started = this.now().getTime();
1821
- try {
1822
- const result = await fn();
1823
- this.emit(message, { data: { ms: this.now().getTime() - started, ok: true } });
1824
- return result;
1825
- } catch (err) {
1826
- this.error(`${message} \u2014 ${err.message}`, {
1827
- data: { ms: this.now().getTime() - started, ok: false }
1828
- });
1829
- throw err;
1830
- }
1831
- }
1832
- };
1833
- var eventFileFor = (stateDir) => join4(stateDir, "events.jsonl");
1834
-
1835
1892
  // src/tracker.ts
1836
1893
  import { hostname } from "os";
1837
1894
  import { TablationClient } from "@tablation/client";
@@ -2131,7 +2188,7 @@ function describeRelease(o, route) {
2131
2188
  // src/install.ts
2132
2189
  import { dirname as dirname3, join as join5, extname } from "path";
2133
2190
  import { homedir as homedir2 } from "os";
2134
- import { accessSync, constants, existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync5, statSync as statSync2, writeFileSync as writeFileSync2, unlinkSync, realpathSync } from "fs";
2191
+ import { accessSync, constants, existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync5, statSync as statSync3, writeFileSync as writeFileSync2, unlinkSync, realpathSync } from "fs";
2135
2192
  import { spawnSync } from "child_process";
2136
2193
  import { createHash } from "crypto";
2137
2194
 
@@ -2185,7 +2242,7 @@ function findOnPath(bin, searchPath) {
2185
2242
  for (const candidate of candidates) {
2186
2243
  try {
2187
2244
  accessSync(candidate, constants.X_OK);
2188
- if (statSync2(candidate).isFile()) return candidate;
2245
+ if (statSync3(candidate).isFile()) return candidate;
2189
2246
  } catch {
2190
2247
  }
2191
2248
  }
@@ -2648,10 +2705,10 @@ import { execFileSync } from "child_process";
2648
2705
  import {
2649
2706
  existsSync as existsSync6,
2650
2707
  mkdirSync as mkdirSync4,
2651
- openSync,
2652
- closeSync,
2708
+ openSync as openSync2,
2709
+ closeSync as closeSync2,
2653
2710
  writeSync,
2654
- statSync as statSync3,
2711
+ statSync as statSync4,
2655
2712
  unlinkSync as unlinkSync2
2656
2713
  } from "fs";
2657
2714
  import { dirname as dirname4, basename as basename3 } from "path";
@@ -3047,10 +3104,10 @@ function acquireCheckoutLock(dir, timeoutMs = 3e4) {
3047
3104
  const deadline = Date.now() + timeoutMs;
3048
3105
  for (; ; ) {
3049
3106
  try {
3050
- const fd = openSync(lockPath, "wx");
3107
+ const fd = openSync2(lockPath, "wx");
3051
3108
  writeSync(fd, `${process.pid}
3052
3109
  `);
3053
- closeSync(fd);
3110
+ closeSync2(fd);
3054
3111
  return () => {
3055
3112
  try {
3056
3113
  unlinkSync2(lockPath);
@@ -3061,7 +3118,7 @@ function acquireCheckoutLock(dir, timeoutMs = 3e4) {
3061
3118
  const err = e;
3062
3119
  if (err.code !== "EEXIST") throw err;
3063
3120
  try {
3064
- if (Date.now() - statSync3(lockPath).mtimeMs > CHECKOUT_LOCK_STALE_MS) {
3121
+ if (Date.now() - statSync4(lockPath).mtimeMs > CHECKOUT_LOCK_STALE_MS) {
3065
3122
  try {
3066
3123
  unlinkSync2(lockPath);
3067
3124
  } catch {
@@ -5398,7 +5455,7 @@ var alreadyReported = (ticket, g, comments) => comments.some((c) => c.ticket_id
5398
5455
 
5399
5456
  // src/agent.ts
5400
5457
  import { spawn as spawn2 } from "child_process";
5401
- import { readFileSync as readFileSync6, existsSync as existsSync7, statSync as statSync4, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4, createWriteStream } from "fs";
5458
+ import { readFileSync as readFileSync6, existsSync as existsSync7, statSync as statSync5, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4, createWriteStream } from "fs";
5402
5459
  import { finished } from "stream/promises";
5403
5460
  import { join as join7, dirname as dirname5 } from "path";
5404
5461
  import { createHash as createHash2 } from "crypto";
@@ -5869,7 +5926,7 @@ function planAgentRun(o) {
5869
5926
  let digest;
5870
5927
  let ageSeconds;
5871
5928
  if (existsSync7(digestPath2)) {
5872
- ageSeconds = Math.floor((now() - statSync4(digestPath2).mtimeMs) / 1e3);
5929
+ ageSeconds = Math.floor((now() - statSync5(digestPath2).mtimeMs) / 1e3);
5873
5930
  if (ageSeconds <= DIGEST_MAX_AGE_SECONDS) digest = readFileSync6(digestPath2, "utf8");
5874
5931
  }
5875
5932
  const common = readFileSync6(commonPath, "utf8");
@@ -6600,7 +6657,7 @@ Wrote ${o.configPath}.
6600
6657
  }
6601
6658
 
6602
6659
  // src/release-run.ts
6603
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, existsSync as existsSync11, copyFileSync, mkdirSync as mkdirSync7, mkdtempSync, rmSync as rmSync2 } from "fs";
6660
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, existsSync as existsSync11, copyFileSync, mkdirSync as mkdirSync7, mkdtempSync, rmSync as rmSync3 } from "fs";
6604
6661
  import { dirname as dirname8, join as join11 } from "path";
6605
6662
  import { tmpdir as tmpdir2 } from "os";
6606
6663
 
@@ -7383,7 +7440,7 @@ function openReleaseCheckout(o) {
7383
7440
  const dir = join11(root, "checkout");
7384
7441
  const close = () => {
7385
7442
  gitOk(o.cwd, ["worktree", "remove", "--force", dir]);
7386
- rmSync2(root, { recursive: true, force: true });
7443
+ rmSync3(root, { recursive: true, force: true });
7387
7444
  gitOk(o.cwd, ["worktree", "prune"]);
7388
7445
  gitOk(o.cwd, ["branch", "-D", branch]);
7389
7446
  };
@@ -7866,7 +7923,7 @@ function missingReleaserWarning(repo, release, ships, now = Date.now()) {
7866
7923
  }
7867
7924
 
7868
7925
  // src/watch.ts
7869
- import { watch as watchFile, existsSync as existsSync12, statSync as statSync5, openSync as openSync2, readSync, closeSync as closeSync2 } from "fs";
7926
+ import { watch as watchFile, existsSync as existsSync12, statSync as statSync6, openSync as openSync3, readSync, closeSync as closeSync3 } from "fs";
7870
7927
  var E = "\x1B[";
7871
7928
  var dim = (s) => `${E}2m${s}${E}0m`;
7872
7929
  var bold = (s) => `${E}1m${s}${E}0m`;
@@ -7968,13 +8025,13 @@ function renderHeaders(states, now = Date.now()) {
7968
8025
  }
7969
8026
  function readFrom(file, from) {
7970
8027
  if (!existsSync12(file)) return { events: [], offset: from };
7971
- const size = statSync5(file).size;
8028
+ const size = statSync6(file).size;
7972
8029
  if (size < from) return readFrom(file, 0);
7973
8030
  if (size === from) return { events: [], offset: from };
7974
- const fd = openSync2(file, "r");
8031
+ const fd = openSync3(file, "r");
7975
8032
  const buf = Buffer.alloc(size - from);
7976
8033
  readSync(fd, buf, 0, buf.length, from);
7977
- closeSync2(fd);
8034
+ closeSync3(fd);
7978
8035
  const text = buf.toString("utf8");
7979
8036
  const lastNewline = text.lastIndexOf("\n");
7980
8037
  if (lastNewline === -1) return { events: [], offset: from };
@@ -9465,7 +9522,7 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
9465
9522
  }
9466
9523
 
9467
9524
  // src/stream-sweep.ts
9468
- import { existsSync as existsSync16, readdirSync as readdirSync3, rmSync as rmSync3, statSync as statSync6 } from "fs";
9525
+ import { existsSync as existsSync16, readdirSync as readdirSync3, rmSync as rmSync4, statSync as statSync7 } from "fs";
9469
9526
  import { join as join15 } from "path";
9470
9527
  var DAY_MS2 = 24 * 60 * 60 * 1e3;
9471
9528
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
@@ -9474,7 +9531,7 @@ function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
9474
9531
  const cutoff = now - retentionDays * DAY_MS2;
9475
9532
  return readdirSync3(dir).map((name) => join15(dir, name)).filter((path) => {
9476
9533
  try {
9477
- return statSync6(path).mtimeMs < cutoff;
9534
+ return statSync7(path).mtimeMs < cutoff;
9478
9535
  } catch {
9479
9536
  return false;
9480
9537
  }
@@ -9488,7 +9545,7 @@ function applyStreamSweep(paths, dryRun, log) {
9488
9545
  continue;
9489
9546
  }
9490
9547
  try {
9491
- rmSync3(path, { force: true });
9548
+ rmSync4(path, { force: true });
9492
9549
  removed++;
9493
9550
  log.emit(`removed stream artifact ${path}`, { step: "worktree" });
9494
9551
  } catch (e) {
@@ -9497,9 +9554,20 @@ function applyStreamSweep(paths, dryRun, log) {
9497
9554
  }
9498
9555
  return removed;
9499
9556
  }
9557
+ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
9558
+ if (!existsSync16(stateDir)) return [];
9559
+ const cutoff = now - retentionDays * DAY_MS2;
9560
+ return readdirSync3(stateDir).filter((name) => ROTATED_EVENTS_RE.test(name)).map((name) => join15(stateDir, name)).filter((path) => {
9561
+ try {
9562
+ return statSync7(path).mtimeMs < cutoff;
9563
+ } catch {
9564
+ return false;
9565
+ }
9566
+ }).sort();
9567
+ }
9500
9568
 
9501
9569
  // src/cli.ts
9502
- import { readFileSync as readFileSync14, existsSync as existsSync20, mkdirSync as mkdirSync11, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync } from "fs";
9570
+ import { readFileSync as readFileSync14, existsSync as existsSync20, mkdirSync as mkdirSync11, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync9 } from "fs";
9503
9571
  import { createInterface } from "readline/promises";
9504
9572
  import { dirname as dirOf, resolve as resolvePath } from "path";
9505
9573
 
@@ -10237,7 +10305,7 @@ function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
10237
10305
  }
10238
10306
 
10239
10307
  // src/daemon.ts
10240
- import { readdirSync as readdirSync6, statSync as statSync7 } from "fs";
10308
+ import { readdirSync as readdirSync6, statSync as statSync8 } from "fs";
10241
10309
  import { join as join19 } from "path";
10242
10310
  var DEFAULT_FLOOR_MS = 5e3;
10243
10311
  var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
@@ -10269,7 +10337,7 @@ async function runDaemonLoop(o) {
10269
10337
  function latestMtime(path) {
10270
10338
  let stat;
10271
10339
  try {
10272
- stat = statSync7(path);
10340
+ stat = statSync8(path);
10273
10341
  } catch {
10274
10342
  return 0;
10275
10343
  }
@@ -10866,6 +10934,7 @@ if (command === "passenger-sync-daemon") {
10866
10934
  const emit = new Emitter({
10867
10935
  route: route?.route ?? "",
10868
10936
  eventFile: dryRun ? void 0 : eventFileFor(cfg.ship.stateDir),
10937
+ rotateBytes: cfg.ship.eventsRotateBytes,
10869
10938
  logFile: dryRun ? void 0 : cfg.ship.logFile,
10870
10939
  console: (l) => process.stderr.write(`${l}
10871
10940
  `)
@@ -10968,7 +11037,10 @@ if (command === "passenger-sync-daemon") {
10968
11037
  emitReleaseSummary(emit, summaries);
10969
11038
  if (!dryRun) state.releaseHeartbeat().touch();
10970
11039
  try {
10971
- const paths = planStreamSweep(cfg.ship.stateDir, cfg.ship.streamRetentionDays);
11040
+ const paths = [
11041
+ ...planStreamSweep(cfg.ship.stateDir, cfg.ship.streamRetentionDays),
11042
+ ...planEventsSweep(cfg.ship.stateDir, cfg.ship.streamRetentionDays)
11043
+ ];
10972
11044
  if (paths.length) {
10973
11045
  emit.enter("worktree");
10974
11046
  const removed = applyStreamSweep(paths, dryRun, emit);
@@ -12020,6 +12092,7 @@ ${changed2.map((c) => ` - ${c}
12020
12092
  newEmitter: () => new Emitter({
12021
12093
  route: route.route,
12022
12094
  eventFile: eventFileFor(cfg.ship.stateDir),
12095
+ rotateBytes: cfg.ship.eventsRotateBytes,
12023
12096
  logFile: cfg.ship.logFile,
12024
12097
  console: (l) => process.stderr.write(`${l}
12025
12098
  `)
@@ -12629,7 +12702,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
12629
12702
  const tmpPath = `${resolvedPath}.tmp${process.pid}`;
12630
12703
  writeFileSync9(tmpPath, `${JSON.stringify(resolved, null, 2)}
12631
12704
  `);
12632
- renameSync(tmpPath, resolvedPath);
12705
+ renameSync2(tmpPath, resolvedPath);
12633
12706
  process.stderr.write(`Resolved ids written to ${resolvedPath}
12634
12707
  `);
12635
12708
  }
@@ -12846,7 +12919,17 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
12846
12919
  { step: "worktree" }
12847
12920
  );
12848
12921
  }
12849
- const streamPaths = planStreamSweep(cfg.ship.stateDir, cfg.ship.streamRetentionDays);
12922
+ if (!dryRun) {
12923
+ const rotated = rotateEventsFile(eventFileFor(cfg.ship.stateDir), cfg.ship.eventsRotateBytes);
12924
+ if (rotated) {
12925
+ anything = true;
12926
+ emit.emit(`rotated oversized event log to ${rotated}`, { step: "worktree" });
12927
+ }
12928
+ }
12929
+ const streamPaths = [
12930
+ ...planStreamSweep(cfg.ship.stateDir, cfg.ship.streamRetentionDays),
12931
+ ...planEventsSweep(cfg.ship.stateDir, cfg.ship.streamRetentionDays)
12932
+ ];
12850
12933
  if (streamPaths.length) {
12851
12934
  anything = true;
12852
12935
  const removed = applyStreamSweep(streamPaths, dryRun, emit);
@@ -13349,6 +13432,15 @@ watermark: ${state.watermark()}
13349
13432
  routes: ${cfg.routes.map((c) => c.route).join(", ")}
13350
13433
  `
13351
13434
  );
13435
+ try {
13436
+ const evFile = eventFileFor(cfg.ship.stateDir);
13437
+ const evSize = existsSync20(evFile) ? statSync9(evFile).size : 0;
13438
+ process.stdout.write(
13439
+ evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
13440
+ ` : "events log: ok\n"
13441
+ );
13442
+ } catch {
13443
+ }
13352
13444
  const tracker = new Tracker(route, cfg.ship);
13353
13445
  const contractProblems = validateContract(tracker.contract);
13354
13446
  process.stdout.write(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",