@tablation/crew 0.82.1 → 0.84.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 +103 -5
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -34,6 +34,29 @@ function doctorBaseReport(r, base, health) {
34
34
  }
35
35
  }
36
36
 
37
+ // src/periscopes.ts
38
+ import { lookup } from "dns/promises";
39
+ function periscopeRoutes(cfg) {
40
+ return cfg.routes.filter((r) => r.enabled && r.hostPeriscopes).map((r) => r.route);
41
+ }
42
+ function periscopeSummary(cfg) {
43
+ const routes = periscopeRoutes(cfg);
44
+ const limits = `max ${cfg.ship.maxPeriscopes}, idle ${cfg.ship.periscopeIdleMinutes} min`;
45
+ if (routes.length === 0) return `off (no enabled route sets hostPeriscopes: true; ${limits})`;
46
+ return `ON for ${routes.join(", ")} (host ${cfg.ship.previewHost ?? "NOT SET (ship.previewHost)"}, ${limits})`;
47
+ }
48
+ async function previewHostProblem(cfg, resolve6 = (h) => lookup(h)) {
49
+ if (periscopeRoutes(cfg).length === 0) return void 0;
50
+ const host = cfg.ship.previewHost;
51
+ if (!host) return "ship.previewHost is not set \u2014 a route hosts periscopes, so Passengers have no host to be sent to";
52
+ try {
53
+ await resolve6(host);
54
+ } catch {
55
+ return `ship.previewHost "${host}" does not resolve from this machine`;
56
+ }
57
+ return void 0;
58
+ }
59
+
37
60
  // src/config.ts
38
61
  import { readFileSync as readFileSync8, existsSync as existsSync7 } from "fs";
39
62
  import { resolve, dirname as dirname3, isAbsolute, join as join9, basename as basename3 } from "path";
@@ -1448,6 +1471,7 @@ var HOOK_NAMES = [
1448
1471
  "ports",
1449
1472
  "isolate",
1450
1473
  "handoff",
1474
+ "preview",
1451
1475
  "ci_status",
1452
1476
  "version",
1453
1477
  "bump",
@@ -2033,6 +2057,8 @@ var ROLE_LABEL = {
2033
2057
  };
2034
2058
  var ConfigError = class extends Error {
2035
2059
  };
2060
+ var DEFAULT_MAX_PERISCOPES = 3;
2061
+ var DEFAULT_PERISCOPE_IDLE_MINUTES = 120;
2036
2062
  function parseCloneScheme(v) {
2037
2063
  if (v === void 0 || v === null || v === "") return void 0;
2038
2064
  if (v === "ssh" || v === "https") return v;
@@ -2064,7 +2090,10 @@ var SHIP_KEYS = /* @__PURE__ */ new Set([
2064
2090
  "relayHttpPort",
2065
2091
  "relayPublicDomain",
2066
2092
  "passengerImage",
2067
- "cloneScheme"
2093
+ "cloneScheme",
2094
+ "previewHost",
2095
+ "maxPeriscopes",
2096
+ "periscopeIdleMinutes"
2068
2097
  ]);
2069
2098
  var ROUTE_KEYS = /* @__PURE__ */ new Set([
2070
2099
  "route",
@@ -2087,6 +2116,7 @@ var ROUTE_KEYS = /* @__PURE__ */ new Set([
2087
2116
  "resolved",
2088
2117
  "promptSet",
2089
2118
  "hostPassengers",
2119
+ "hostPeriscopes",
2090
2120
  "toolchain"
2091
2121
  ]);
2092
2122
  function parseWorktreeName(v, where, file) {
@@ -2316,6 +2346,7 @@ function parseResolved(raw, m, where) {
2316
2346
  areaId: raw.areaId,
2317
2347
  shipsModelId: raw.shipsModelId,
2318
2348
  epicsModelId: raw.epicsModelId,
2349
+ periscopesModelId: raw.periscopesModelId,
2319
2350
  locksModelId: raw.locksModelId,
2320
2351
  recordLinkViewId: raw.recordLinkViewId,
2321
2352
  reposModelId: raw.reposModelId,
@@ -2397,6 +2428,14 @@ Create the first of those:
2397
2428
  if (!Number.isInteger(streamRetentionDays) || streamRetentionDays < 1) {
2398
2429
  missing.add(`ship.streamRetentionDays must be a positive integer (got "${shipRaw.streamRetentionDays}")`);
2399
2430
  }
2431
+ const maxPeriscopes = shipRaw.maxPeriscopes === void 0 ? DEFAULT_MAX_PERISCOPES : Number(shipRaw.maxPeriscopes);
2432
+ if (!Number.isInteger(maxPeriscopes) || maxPeriscopes < 1) {
2433
+ missing.add(`ship.maxPeriscopes must be a positive integer (got "${shipRaw.maxPeriscopes}")`);
2434
+ }
2435
+ const periscopeIdleMinutes = shipRaw.periscopeIdleMinutes === void 0 ? DEFAULT_PERISCOPE_IDLE_MINUTES : Number(shipRaw.periscopeIdleMinutes);
2436
+ if (!Number.isInteger(periscopeIdleMinutes) || periscopeIdleMinutes < 1) {
2437
+ missing.add(`ship.periscopeIdleMinutes must be a positive integer (got "${shipRaw.periscopeIdleMinutes}")`);
2438
+ }
2400
2439
  const eventsRotateBytes = shipRaw.eventsRotateBytes === void 0 ? DEFAULT_EVENTS_ROTATE_BYTES : Number(shipRaw.eventsRotateBytes);
2401
2440
  if (!Number.isInteger(eventsRotateBytes) || eventsRotateBytes < 1) {
2402
2441
  missing.add(`ship.eventsRotateBytes must be a positive integer (got "${shipRaw.eventsRotateBytes}")`);
@@ -2466,7 +2505,10 @@ Create the first of those:
2466
2505
  relayHttpPort: shipRaw.relayHttpPort !== void 0 ? Number(shipRaw.relayHttpPort) : 443,
2467
2506
  relayPublicDomain: shipRaw.relayPublicDomain ? String(shipRaw.relayPublicDomain) : void 0,
2468
2507
  passengerImage: shipRaw.passengerImage ? String(shipRaw.passengerImage) : void 0,
2469
- cloneScheme: parseCloneScheme(shipRaw.cloneScheme)
2508
+ cloneScheme: parseCloneScheme(shipRaw.cloneScheme),
2509
+ previewHost: shipRaw.previewHost ? String(shipRaw.previewHost).trim() || void 0 : void 0,
2510
+ maxPeriscopes,
2511
+ periscopeIdleMinutes
2470
2512
  },
2471
2513
  routes,
2472
2514
  crewHome,
@@ -2567,6 +2609,9 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, t
2567
2609
  missing.add(`${where}.toolchain "${toolchain}" is not defined in ship.toolchains (have: ${[...toolchainNames].join(", ")})`);
2568
2610
  }
2569
2611
  const route = missing.req(c?.route, `${where}.route`);
2612
+ if (c?.hostPeriscopes === true && c?.hostPassengers !== true) {
2613
+ missing.add(`${where} (route "${route}") sets hostPeriscopes: true without hostPassengers: true \u2014 a Passenger must be able to board before they can open a periscope`);
2614
+ }
2570
2615
  const routeParts = route ? route.split("/") : [];
2571
2616
  const routeWellFormed = routeParts.length === 2 && routeParts.every(Boolean);
2572
2617
  if (route && !routeWellFormed) {
@@ -2641,7 +2686,8 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, t
2641
2686
  },
2642
2687
  release: parseReleaseOverride(c?.release, where, file),
2643
2688
  branch: parseBranchOverride(c?.branch, where, file),
2644
- hostPassengers: c?.hostPassengers === true
2689
+ hostPassengers: c?.hostPassengers === true,
2690
+ hostPeriscopes: c?.hostPeriscopes === true
2645
2691
  };
2646
2692
  const resolvedFile = routeWellFormed ? readResolvedFile(stateDir, route) : void 0;
2647
2693
  result.resolved = parseResolved(resolvedFile ?? c?.resolved, missing, where);
@@ -5114,6 +5160,7 @@ var HOOK_PURPOSE = [
5114
5160
  ["isolate", "point this worktree at state of its own; prints KEY=value overrides to export"],
5115
5161
  ["ports", "this worktree's port assignments; prints KEY=value overrides to export"],
5116
5162
  ["handoff", "leave the operator able to open what you built; put its output in your progress comment"],
5163
+ ["preview", "start this worktree's dev server for a viewer, bound to CREW_PREVIEW_HOST, detached; its last stdout line is the URL"],
5117
5164
  ["test", "run the suite"],
5118
5165
  ["build", "build it"],
5119
5166
  ["ci_status", "after you push and before you hand off: exit 0 means CI is green for your branch; on failure fix and push again, and say in your progress comment if it is still pending"]
@@ -5558,6 +5605,19 @@ function validateContract(c) {
5558
5605
  return problems;
5559
5606
  }
5560
5607
  var EVENT_LOG_MISSING = "the Issues table has no event log \u2014 crew writes its audit trail there (CREW-1574). In the app, open the Issues table's Data & lifecycle tab and turn on Event log, then rerun.";
5608
+ var PERISCOPE_COLUMNS = [
5609
+ "ticket_id",
5610
+ "ship_id",
5611
+ "url",
5612
+ "host",
5613
+ "worktree",
5614
+ "branch",
5615
+ "state",
5616
+ "isolated",
5617
+ "started_at",
5618
+ "last_activity_at",
5619
+ "expires_at"
5620
+ ];
5561
5621
 
5562
5622
  // src/agent-log.ts
5563
5623
  import { existsSync as existsSync12, mkdirSync as mkdirSync7, readdirSync as readdirSync5, readFileSync as readFileSync12, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "fs";
@@ -6806,6 +6866,19 @@ var Tracker = class {
6806
6866
  async hasPublicReplyColumn() {
6807
6867
  return (await this.authorshipProbe()).commentPublicReply;
6808
6868
  }
6869
+ /**
6870
+ * Whether this workspace has the optional Periscopes table (EPIC-077), and
6871
+ * which of the columns the crew writes (`PERISCOPE_COLUMNS`) it lacks.
6872
+ * `present: false` is a legitimate shape, never a fault. A table whose
6873
+ * metadata cannot be read reports every column missing, as the authorship
6874
+ * probe reads an unreadable table as "column absent".
6875
+ */
6876
+ async periscopesStatus() {
6877
+ const modelId = this.route.resolved?.periscopesModelId;
6878
+ if (!modelId) return { present: false, missing: [] };
6879
+ const have = await this.columnNames(modelId);
6880
+ return { present: true, modelId, missing: PERISCOPE_COLUMNS.filter((c) => !have.has(c)) };
6881
+ }
6809
6882
  /**
6810
6883
  * The authorship columns this workspace is missing, as `table.column` names
6811
6884
  * for `crew doctor` to report (CREW-1371). Empty on a fully updated install.
@@ -7733,7 +7806,7 @@ function skillMarkerNames(text) {
7733
7806
  }
7734
7807
  return names;
7735
7808
  }
7736
- function expandSkillMarkers(text, lookup, opts = {}) {
7809
+ function expandSkillMarkers(text, lookup2, opts = {}) {
7737
7810
  const maxDepth = opts.maxDepth ?? MAX_INCLUDE_DEPTH;
7738
7811
  const maxBytes = opts.maxBytes ?? MAX_INCLUDED_BYTES;
7739
7812
  const warnings = [];
@@ -7749,7 +7822,7 @@ function expandSkillMarkers(text, lookup, opts = {}) {
7749
7822
  warnings.push(`skill include "${name}" left literal: nested deeper than ${maxDepth} levels`);
7750
7823
  return literal;
7751
7824
  }
7752
- const body = lookup(name);
7825
+ const body = lookup2(name);
7753
7826
  if (body === void 0) throw new SkillIncludeError(`no skill named "${name}" for the include ${literal}`);
7754
7827
  const trimmed = body.replace(/^\n+/, "").replace(/\n+$/, "");
7755
7828
  const bytes = Buffer.byteLength(trimmed, "utf8");
@@ -13719,6 +13792,7 @@ function forbiddenDetail(e, route) {
13719
13792
  [r?.shipsModelId, "Ships"],
13720
13793
  [r?.locksModelId, "Locks"],
13721
13794
  [r?.epicsModelId, "Epics"],
13795
+ [r?.periscopesModelId, "Periscopes"],
13722
13796
  [r?.reposModelId, "Repos"]
13723
13797
  ];
13724
13798
  const table2 = modelId ? known.find(([id]) => id && id === modelId)?.[1] ?? `table ${modelId}` : void 0;
@@ -14563,6 +14637,7 @@ async function discover(o) {
14563
14637
  out.reposModelId = find("Repos");
14564
14638
  out.shipsModelId = find("Ships");
14565
14639
  out.epicsModelId = find("Epics");
14640
+ out.periscopesModelId = find("Periscopes");
14566
14641
  out.locksModelId = find("Locks");
14567
14642
  try {
14568
14643
  const views = await get(o, `/views?workspaceId=${ws.id}`);
@@ -14614,6 +14689,7 @@ async function discover(o) {
14614
14689
  shipColumns = new Set((shipsModel.fields ?? []).map((f) => f.columnName));
14615
14690
  } catch {
14616
14691
  }
14692
+ const hostPeriscopes = shipColumns.has("host_periscopes") ? o.ship.hostPeriscopes : void 0;
14617
14693
  let ownerId;
14618
14694
  if (out.meId) {
14619
14695
  if (shipColumns.has("owner_id")) ownerId = out.meId;
@@ -14656,6 +14732,7 @@ async function discover(o) {
14656
14732
  }
14657
14733
  if (writeMachineId) patchBody.machine_id = machineId;
14658
14734
  if ((mine[0].host_passengers ?? false) !== hostPassengers) patchBody.host_passengers = hostPassengers;
14735
+ if (hostPeriscopes !== void 0 && (mine[0].host_periscopes ?? false) !== hostPeriscopes) patchBody.host_periscopes = hostPeriscopes;
14659
14736
  if (sshPublicKey !== void 0 && (mine[0].ssh_public_key ?? "") !== sshPublicKey) {
14660
14737
  patchBody.ssh_public_key = sshPublicKey;
14661
14738
  }
@@ -14663,6 +14740,7 @@ async function discover(o) {
14663
14740
  if (Object.keys(patchBody).length > 0) {
14664
14741
  await patch(o, `/data-models/${out.shipsModelId}/records/${shipRowId}`, patchBody);
14665
14742
  if ("host_passengers" in patchBody) provisioning.push(`synced host_passengers=${hostPassengers} onto Ships row "${o.ship.name}"`);
14743
+ if ("host_periscopes" in patchBody) provisioning.push(`synced host_periscopes=${hostPeriscopes} onto Ships row "${o.ship.name}"`);
14666
14744
  if ("ssh_public_key" in patchBody) provisioning.push(`synced ssh_public_key onto Ships row "${o.ship.name}"`);
14667
14745
  if ("owner_id" in patchBody) provisioning.push(`recorded owner on Ships row "${o.ship.name}"`);
14668
14746
  }
@@ -14671,6 +14749,7 @@ async function discover(o) {
14671
14749
  name: o.ship.name,
14672
14750
  ...o.ship.platform ? { platform: o.ship.platform } : {},
14673
14751
  host_passengers: hostPassengers,
14752
+ ...hostPeriscopes !== void 0 ? { host_periscopes: hostPeriscopes } : {},
14674
14753
  ...sshPublicKey !== void 0 ? { ssh_public_key: sshPublicKey } : {},
14675
14754
  ...ownerId !== void 0 ? { owner_id: ownerId } : {},
14676
14755
  ...writeMachineId ? { machine_id: machineId } : {}
@@ -20350,6 +20429,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
20350
20429
  platform: cfg.ship.platform,
20351
20430
  machineId,
20352
20431
  hostPassengers: firstRun?.answers.hostPassengers ?? existingRoute?.hostPassengers === true,
20432
+ hostPeriscopes: existingRoute?.hostPeriscopes === true,
20353
20433
  sshPublicKey,
20354
20434
  lastSyncedName: (r) => readSyncedName(resolvedPathFor(cfg.ship.stateDir, r))
20355
20435
  };
@@ -20561,6 +20641,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
20561
20641
  areaId: found.areaId,
20562
20642
  shipsModelId: found.shipsModelId,
20563
20643
  epicsModelId: found.epicsModelId,
20644
+ periscopesModelId: found.periscopesModelId,
20564
20645
  locksModelId: found.locksModelId,
20565
20646
  recordLinkViewId: found.recordLinkViewId,
20566
20647
  reposModelId: found.reposModelId,
@@ -21812,6 +21893,7 @@ ${owed.map((l) => ` ${l}
21812
21893
  `ship: ${cfg.ship.name} (${cfg.ship.platform})
21813
21894
  route: ${route.route} -> ${route.dir}
21814
21895
  enabled: ${route.enabled}
21896
+ periscopes: ${periscopeSummary(cfg)}
21815
21897
  crew: ${state.isPaused() ? "paused" : "active"}
21816
21898
  release: ${state.releaseSkipReason() ? "paused" : "active"}
21817
21899
  watermark: ${state.watermark()}
@@ -21929,6 +22011,8 @@ watermark: ${state.watermark()}
21929
22011
  }
21930
22012
  }
21931
22013
  for (const message of roleCapWarnings(cfg.ship)) problems.push({ severity: "note", kind: "role_cap_above_ship_cap", message });
22014
+ const previewProblem2 = await previewHostProblem(cfg);
22015
+ if (previewProblem2) problems.push({ severity: "blocker", kind: "preview_host", message: previewProblem2 });
21932
22016
  problems.push(...attentionProblems(openShipAttention(state), problems));
21933
22017
  if (problems.some((p) => p.severity === "blocker")) process.exitCode = 1;
21934
22018
  process.stdout.write(`${JSON.stringify({ offline: flag("offline"), pushAuth, tools: toolRows, problems, passed }, null, 2)}
@@ -21942,6 +22026,14 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
21942
22026
  );
21943
22027
  for (const w of roleCapWarnings(cfg.ship)) process.stdout.write(`role concurrency: WARN ${w}
21944
22028
  `);
22029
+ process.stdout.write(`periscopes: ${periscopeSummary(cfg)}
22030
+ `);
22031
+ const previewProblem = await previewHostProblem(cfg);
22032
+ if (previewProblem) {
22033
+ process.stdout.write(` BLOCKER ${previewProblem}
22034
+ `);
22035
+ process.exitCode = 1;
22036
+ }
21945
22037
  try {
21946
22038
  const evFile = eventFileFor(cfg.ship.stateDir);
21947
22039
  const evSize = existsSync35(evFile) ? statSync10(evFile).size : 0;
@@ -22043,6 +22135,12 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
22043
22135
  process.stdout.write(
22044
22136
  await tracker.hasPublicReplyColumn() ? `public replies: Comments.${tracker.contract.comments.publicReply} present \u2014 public reporters are labelled in the digest
22045
22137
  ` : `public replies: Comments.${tracker.contract.comments.publicReply} absent \u2014 no public intake form, nothing to guard
22138
+ `
22139
+ );
22140
+ const periscopes = await tracker.periscopesStatus();
22141
+ process.stdout.write(
22142
+ !periscopes.present ? "periscopes: Periscopes table absent \u2014 this workspace cannot host periscopes\n" : periscopes.missing.length === 0 ? `periscopes: Periscopes table present (${periscopes.modelId})
22143
+ ` : `periscopes: Periscopes table present (${periscopes.modelId}) but missing ${periscopes.missing.join(", ")} \u2014 update the Issues template
22046
22144
  `
22047
22145
  );
22048
22146
  process.stdout.write(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.82.1",
3
+ "version": "0.84.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",