pi-straw-boss 0.58.3 → 0.59.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.
- package/dist/server/main.mjs +227 -26
- package/package.json +1 -1
- package/prompts/apps-config.md +6 -3
- package/prompts/integrator.md +18 -9
- package/prompts/planning.md +3 -3
- package/prompts/tower-run.md +1 -1
- package/src/link/dialogs-out.ts +42 -9
- package/src/link/holder.ts +29 -2
- package/src/link/scope.ts +14 -0
- package/src/link/service.ts +7 -0
- package/src/link/unitstatus.ts +15 -9
- package/src/server/allyport.ts +17 -1
- package/src/server/allyroute.ts +2 -0
- package/src/server/dispatcher.ts +28 -0
- package/src/server/guihost.ts +1 -1
- package/src/server/main.ts +7 -6
- package/src/server/revoke.ts +89 -0
- package/src/server/runs.ts +31 -7
- package/src/server/towers.ts +16 -3
package/dist/server/main.mjs
CHANGED
|
@@ -1923,9 +1923,25 @@ function allyPort(link) {
|
|
|
1923
1923
|
async taken(id, identity2) {
|
|
1924
1924
|
await link()?.holder?.taken(id, identity2);
|
|
1925
1925
|
},
|
|
1926
|
+
delivered(id, run3) {
|
|
1927
|
+
link()?.holder?.delivered(id, run3);
|
|
1928
|
+
},
|
|
1929
|
+
// A device is taken as gone only while the box can tell who is in the team: the policy file reads (a refused one answers `reply` for everybody, g346-F003) and the holder reads the roster. A team the unattended policy
|
|
1930
|
+
// no longer names has no holder run and never will: nobody is in it, so its controllers are gone (g346-F005b).
|
|
1931
|
+
known(team) {
|
|
1932
|
+
const service = link();
|
|
1933
|
+
if (!service) return false;
|
|
1934
|
+
const { mode, config } = service.policyLook();
|
|
1935
|
+
if (mode === "refused") return false;
|
|
1936
|
+
if (mode === "unattended" && !config.teams.has(team)) return service.holder?.active === true;
|
|
1937
|
+
return service.holder?.rosterKnown(team) ?? false;
|
|
1938
|
+
},
|
|
1926
1939
|
standing(device, team) {
|
|
1927
1940
|
const service = link();
|
|
1928
|
-
|
|
1941
|
+
if (!service) return void 0;
|
|
1942
|
+
const { mode, config } = service.policyLook();
|
|
1943
|
+
if (mode === "unattended" && !config.teams.has(team)) return void 0;
|
|
1944
|
+
return service.holder?.isMember(team, device) ? { level: service.levelOf(device) } : void 0;
|
|
1929
1945
|
},
|
|
1930
1946
|
project: (team) => link()?.config().teams.get(team)?.project,
|
|
1931
1947
|
thread: (device) => link()?.store.readThread(device).slice(-THREAD_ROWS).map((r) => ({ dir: r.dir, at: r.at, kind: r.kind, text: r.text })) ?? []
|
|
@@ -8017,7 +8033,12 @@ function createGuiHost(o) {
|
|
|
8017
8033
|
let forwardNet;
|
|
8018
8034
|
return {
|
|
8019
8035
|
/** The hub and a net reading that binds its dialogs, for the forwarder an unattended device runs. */
|
|
8020
|
-
dialogs: () => ({
|
|
8036
|
+
dialogs: () => ({
|
|
8037
|
+
hub: theHub(),
|
|
8038
|
+
read: async () => void await (forwardNet ??= netSource({ ...o.tower ? { services: (workspace) => o.tower.view(workspace) } : {}, run: cfg.run, world: cfg.world, sessions, hub: theHub() })).read(),
|
|
8039
|
+
/** The pane of a unit by the key the hub gives its dialogs (known after a `read`). */
|
|
8040
|
+
paneOf: (key2) => forwardNet?.lookup(key2)?.pane
|
|
8041
|
+
}),
|
|
8021
8042
|
/** At the server's start: the GUI on the recorded ports unless it was stopped on purpose (which only the boss's own command undoes). */
|
|
8022
8043
|
async start() {
|
|
8023
8044
|
theHub().track();
|
|
@@ -8411,6 +8432,14 @@ function decodePlaintext(kind, bytes) {
|
|
|
8411
8432
|
}
|
|
8412
8433
|
}
|
|
8413
8434
|
|
|
8435
|
+
// src/link/scope.ts
|
|
8436
|
+
var EVERYONE = "all";
|
|
8437
|
+
function reaches(scope, c, admin) {
|
|
8438
|
+
if (scope === "all") return true;
|
|
8439
|
+
if (admin(c.team, c.device) && (scope.team === void 0 || scope.team === c.team)) return true;
|
|
8440
|
+
return scope.device !== void 0 && scope.device === c.device && (scope.team === void 0 || scope.team === c.team);
|
|
8441
|
+
}
|
|
8442
|
+
|
|
8414
8443
|
// src/link/dialogs-out.ts
|
|
8415
8444
|
var MAX_OPEN2 = 32;
|
|
8416
8445
|
var MAX_SEEN = 1024;
|
|
@@ -8454,6 +8483,15 @@ var DialogForwarder = class {
|
|
|
8454
8483
|
})().finally(() => this.polling = void 0);
|
|
8455
8484
|
return this.polling = run3;
|
|
8456
8485
|
}
|
|
8486
|
+
/** Whose unit asked: looked up until it is known, then kept. A lookup that fails leaves it unknown, which is the admins' alone. */
|
|
8487
|
+
async whose(d) {
|
|
8488
|
+
if (!this.d.dispatcher || d.who) return;
|
|
8489
|
+
d.who = await this.d.dispatcher(d.key).catch(() => void 0);
|
|
8490
|
+
}
|
|
8491
|
+
/** What the question's controllers are: every controller when the box cannot tell whose units are whose, else the dispatcher and the admins. */
|
|
8492
|
+
scopeOf(d) {
|
|
8493
|
+
return this.d.dispatcher ? d.who ? { device: d.who.device, ...d.who.team !== void 0 ? { team: d.who.team } : {} } : {} : EVERYONE;
|
|
8494
|
+
}
|
|
8457
8495
|
async pass() {
|
|
8458
8496
|
const to = this.d.controllers();
|
|
8459
8497
|
if (!to.length && !this.open.size) return;
|
|
@@ -8465,13 +8503,16 @@ var DialogForwarder = class {
|
|
|
8465
8503
|
if (Buffer.byteLength(text5) > LIMITS2.dialog) {
|
|
8466
8504
|
this.skipped.add(id);
|
|
8467
8505
|
this.d.log({ event: "dialog-oversize", dialog: id, bytes: Buffer.byteLength(text5) });
|
|
8468
|
-
|
|
8506
|
+
const unit = { key: o.key };
|
|
8507
|
+
await this.whose(unit);
|
|
8508
|
+
await this.d.notice?.(`oversize/${id}`, { kind: "dialog-oversize", unit: o.key, text: `A question of ${o.owner} was not forwarded: it is ${Buffer.byteLength(text5)} bytes, over the ${LIMITS2.dialog} the link carries. It waits at the device.` }, this.scopeOf(unit));
|
|
8469
8509
|
continue;
|
|
8470
8510
|
}
|
|
8471
8511
|
if (this.open.size >= MAX_OPEN2) continue;
|
|
8472
8512
|
this.open.set(id, { key: o.key, target: o.target, owner: o.owner, text: text5, sent: /* @__PURE__ */ new Map(), away: /* @__PURE__ */ new Set(), claimed: false, since: this.now(), told: false });
|
|
8473
8513
|
}
|
|
8474
8514
|
for (const [id, d] of [...this.open]) {
|
|
8515
|
+
await this.whose(d);
|
|
8475
8516
|
const state = this.d.hub.outcome(id);
|
|
8476
8517
|
if (!state.open) {
|
|
8477
8518
|
this.open.delete(id);
|
|
@@ -8483,17 +8524,22 @@ var DialogForwarder = class {
|
|
|
8483
8524
|
d.unknownAt = void 0;
|
|
8484
8525
|
d.sent.clear();
|
|
8485
8526
|
}
|
|
8527
|
+
const mine = to.filter((c) => reaches(this.scopeOf(d), c, this.d.admin ?? (() => false)));
|
|
8486
8528
|
for (const k of this.d.controllersKnown?.() === false ? [] : [...d.sent.keys()]) {
|
|
8487
|
-
if (
|
|
8529
|
+
if (mine.some((c) => `${c.team}
|
|
8488
8530
|
${c.device}` === k)) continue;
|
|
8489
8531
|
d.sent.delete(k);
|
|
8490
8532
|
await this.send(k, "dialog-close", { dialogId: id, by: "withdrawn" });
|
|
8491
8533
|
}
|
|
8492
8534
|
if (!d.told && !d.claimed && to.length && this.now() - d.since >= UNANSWERED_MS) {
|
|
8493
|
-
d.
|
|
8494
|
-
|
|
8535
|
+
const sent = await this.d.notice?.(`unanswered/${id}`, { kind: "dialog-unanswered", unit: d.key, text: `A question of ${d.owner} has waited ${Math.round((this.now() - d.since) / 6e4)} minutes for an answer.` }, this.scopeOf(d));
|
|
8536
|
+
d.told = sent !== false;
|
|
8495
8537
|
}
|
|
8496
|
-
|
|
8538
|
+
if (!mine.length && !d.unrouted) {
|
|
8539
|
+
d.unrouted = true;
|
|
8540
|
+
this.d.log({ event: "dialog-unrouted", dialog: id, unit: d.key, owner: d.owner });
|
|
8541
|
+
}
|
|
8542
|
+
for (const c of mine) {
|
|
8497
8543
|
const k = `${c.team}
|
|
8498
8544
|
${c.device}`;
|
|
8499
8545
|
const at = d.sent.get(k), up = this.d.online?.(c.team, c.device) ?? true;
|
|
@@ -8532,6 +8578,8 @@ ${device}`);
|
|
|
8532
8578
|
const drop = (reason) => this.d.log({ event: "drop", reason, kind: "dialog-answer", from: a.from, dialog: a.dialogId });
|
|
8533
8579
|
const d = this.open.get(a.dialogId);
|
|
8534
8580
|
if (!d) return drop("dialog-answer-unknown");
|
|
8581
|
+
await this.whose(d);
|
|
8582
|
+
if (!reaches(this.scopeOf(d), { team: a.team, device: a.from }, this.d.admin ?? (() => false))) return drop("dialog-answer-not-recipient");
|
|
8535
8583
|
if (d.target.answer.digest !== a.digest) return drop("dialog-answer-digest");
|
|
8536
8584
|
if (!ANSWER_ID2.test(a.answerId) || this.seen.has(a.answerId)) return drop("dialog-answer-id");
|
|
8537
8585
|
this.seen.add(a.answerId);
|
|
@@ -8597,6 +8645,26 @@ ${dialogId}`;
|
|
|
8597
8645
|
};
|
|
8598
8646
|
var newAnswerId = () => randomBytes12(16).toString("hex");
|
|
8599
8647
|
|
|
8648
|
+
// src/server/dispatcher.ts
|
|
8649
|
+
function dispatcherOf(pane, listed2, allyPanes) {
|
|
8650
|
+
const mine = listed2.find((p) => p.pane_id === pane);
|
|
8651
|
+
if (!mine) return void 0;
|
|
8652
|
+
const ally = allyPanes.find((a) => a.pane === pane);
|
|
8653
|
+
if (ally) return { device: ally.device, ...ally.team ? { team: ally.team } : {} };
|
|
8654
|
+
const device = listed2.find((p) => p.tab_id === mine.tab_id && p.tokens?.sb_opened_by?.startsWith("link:"))?.tokens?.sb_opened_by?.replace(/^(link:)+/, "");
|
|
8655
|
+
return device && /^[a-z2-7]{26}$/.test(device) ? { device } : void 0;
|
|
8656
|
+
}
|
|
8657
|
+
function dispatcherLookup(d) {
|
|
8658
|
+
let listed2;
|
|
8659
|
+
return async (key2) => {
|
|
8660
|
+
const pane = d.paneOf(key2);
|
|
8661
|
+
if (!pane) return void 0;
|
|
8662
|
+
if (!listed2 || d.now() - listed2.at > 2e3) listed2 = { at: d.now(), panes: d.list().catch(() => void 0) };
|
|
8663
|
+
const panes3 = await listed2.panes;
|
|
8664
|
+
return panes3 ? dispatcherOf(pane, panes3, d.allyPanes()) : void 0;
|
|
8665
|
+
};
|
|
8666
|
+
}
|
|
8667
|
+
|
|
8600
8668
|
// src/server/opentower.ts
|
|
8601
8669
|
import { realpathSync as realpathSync7 } from "node:fs";
|
|
8602
8670
|
import { basename as basename8 } from "node:path";
|
|
@@ -10708,8 +10776,14 @@ var LinkHolder = class {
|
|
|
10708
10776
|
...message.awaitsReply !== void 0 ? { awaitsReply: message.awaitsReply } : {}
|
|
10709
10777
|
};
|
|
10710
10778
|
this.o.store.putInbox(item);
|
|
10779
|
+
this.o.store.log({ event: "tower-in", kind: "tower", team: run3.team, from: header2.from, id: item.id, ...message.awaitsReply !== void 0 ? { ask: message.awaitsReply } : {} });
|
|
10711
10780
|
this.thread(header2.from, { dir: "in", at: item.received, id: item.id, kind: "tower", ...message.awaitsReply !== void 0 ? { awaitsReply: message.awaitsReply } : {}, text: message.text });
|
|
10712
10781
|
}
|
|
10782
|
+
/** An ally run took the inbox message `id` into its session: one `link.log` line names the run, the sender, the team and the ask, never the text (docs/specs/2026-11-05-link-shared-box.md, Audit). */
|
|
10783
|
+
delivered(id, run3) {
|
|
10784
|
+
const item = this.o.store.readInbox(id);
|
|
10785
|
+
if (item) this.o.store.log({ event: "tower-run", run: run3, team: item.team, from: item.from, id, ...item.awaitsReply !== void 0 ? { ask: item.awaitsReply } : {} });
|
|
10786
|
+
}
|
|
10713
10787
|
/** A row of a device's thread, under the lock; a store that cannot write it does not lose the message. */
|
|
10714
10788
|
thread(device, row2) {
|
|
10715
10789
|
try {
|
|
@@ -10742,6 +10816,7 @@ var LinkHolder = class {
|
|
|
10742
10816
|
...taken ? { reply: { ask: message.inReplyTo, main: taken.main, mainName: taken.mainName, tower: taken.tower } } : {}
|
|
10743
10817
|
};
|
|
10744
10818
|
this.o.store.putInbox(item);
|
|
10819
|
+
this.o.store.log({ event: "tower-in", kind: "tower-reply", team: run3.team, from: header2.from, id: item.id, ask: message.inReplyTo });
|
|
10745
10820
|
this.thread(header2.from, { dir: "in", at: item.received, id: item.id, kind: "tower-reply", inReplyTo: message.inReplyTo, text: message.text });
|
|
10746
10821
|
}
|
|
10747
10822
|
/** The election's record no longer names this session: nothing is acted on from here, and every stream ends. */
|
|
@@ -10895,7 +10970,7 @@ var LinkHolder = class {
|
|
|
10895
10970
|
store.log({ event: "tower-open", project, from });
|
|
10896
10971
|
const attempt = (async () => {
|
|
10897
10972
|
try {
|
|
10898
|
-
await openTower2(project, toWorkspace);
|
|
10973
|
+
await openTower2(project, toWorkspace, from);
|
|
10899
10974
|
store.log({ event: "tower-opened", project });
|
|
10900
10975
|
} catch (error) {
|
|
10901
10976
|
store.log({ event: "tower-open-failed", project, error: error.message });
|
|
@@ -11078,6 +11153,17 @@ ${target.id}`, { text: req.text, at: this.now() });
|
|
|
11078
11153
|
}
|
|
11079
11154
|
return items.sort((a, b) => a.received - b.received || (a.id < b.id ? -1 : 1));
|
|
11080
11155
|
}
|
|
11156
|
+
/** Whether this holder can tell who is in `team` right now: it runs, the team runs and has not died, and its roster reads. A device is taken as gone only while this is true (the revocation of a controller's mains). */
|
|
11157
|
+
rosterKnown(team) {
|
|
11158
|
+
const run3 = this.runs.get(team);
|
|
11159
|
+
if (!this.running || !run3 || run3.dead) return false;
|
|
11160
|
+
try {
|
|
11161
|
+
this.loadTeam(team);
|
|
11162
|
+
return true;
|
|
11163
|
+
} catch {
|
|
11164
|
+
return false;
|
|
11165
|
+
}
|
|
11166
|
+
}
|
|
11081
11167
|
/** Whether `device` is a member of `team` on this computer's roster (a run whose sender left is ended). */
|
|
11082
11168
|
isMember(team, device) {
|
|
11083
11169
|
try {
|
|
@@ -11187,6 +11273,14 @@ ${target.id}`, { text: req.text, at: this.now() });
|
|
|
11187
11273
|
dialogControllers() {
|
|
11188
11274
|
return this.controllerRead().list;
|
|
11189
11275
|
}
|
|
11276
|
+
/** Whether `device` is an admin of `team`'s roster now (an admin is told every question and notice of the box). False for a team that is not running or a roster that cannot be read. */
|
|
11277
|
+
isAdmin(team, device) {
|
|
11278
|
+
try {
|
|
11279
|
+
return this.loadTeam(team).state.members.get(device)?.role === "admin";
|
|
11280
|
+
} catch {
|
|
11281
|
+
return false;
|
|
11282
|
+
}
|
|
11283
|
+
}
|
|
11190
11284
|
/** False while the controller list cannot be trusted to be complete (the holder is not running, or a team's roster could not be read): a caller does not withdraw anything then. */
|
|
11191
11285
|
controllersKnown() {
|
|
11192
11286
|
return this.controllerRead().known;
|
|
@@ -12288,6 +12382,13 @@ function createLinkService({ kit, stateDir, version, started, pid = process.pid,
|
|
|
12288
12382
|
return store;
|
|
12289
12383
|
},
|
|
12290
12384
|
attended,
|
|
12385
|
+
/** How the policy file reads now: a refused one means the box cannot tell who may do what (g346-F003). */
|
|
12386
|
+
policyMode: () => policy().mode,
|
|
12387
|
+
/** The policy mode and the effective config from one read of the policy file, so the two cannot disagree. */
|
|
12388
|
+
policyLook() {
|
|
12389
|
+
const p = policy();
|
|
12390
|
+
return { mode: p.mode, config: readEffectiveConfig(p, stateDir, { home, agentDir }) };
|
|
12391
|
+
},
|
|
12291
12392
|
/** The teams and projects of config.json, read fresh. */
|
|
12292
12393
|
config,
|
|
12293
12394
|
/** The level this computer gives a device (the policy file's, else `link.trust`, else `reply`): an ally run is claimed at it. */
|
|
@@ -13595,11 +13696,11 @@ ${c.device}`, units = mine.map((u) => ({ ...u, report: cutBytes(this.d.report(u.
|
|
|
13595
13696
|
}
|
|
13596
13697
|
}
|
|
13597
13698
|
}
|
|
13598
|
-
/** Tells
|
|
13599
|
-
async notify(key2, notice) {
|
|
13600
|
-
if (this.told.has(key2)) return;
|
|
13601
|
-
const to = this.d.controllers();
|
|
13602
|
-
if (!to.length) return;
|
|
13699
|
+
/** Tells the controllers `scope` covers (the dispatcher and the admins; `EVERYONE` for all), once per `key`; nothing is remembered when there was nobody to tell, so the next trigger with the same key still goes out. Resolves whether anybody was addressed. */
|
|
13700
|
+
async notify(key2, notice, scope) {
|
|
13701
|
+
if (this.told.has(key2)) return false;
|
|
13702
|
+
const to = this.d.controllers().filter((c) => reaches(scope, c, this.d.admin ?? (() => false)));
|
|
13703
|
+
if (!to.length) return false;
|
|
13603
13704
|
this.told.add(key2);
|
|
13604
13705
|
while (this.told.size > TOLD_MAX) this.told.delete(this.told.values().next().value);
|
|
13605
13706
|
this.d.log({ event: "notice", key: key2, kind: notice.kind, unit: notice.unit });
|
|
@@ -13610,14 +13711,17 @@ ${c.device}`, units = mine.map((u) => ({ ...u, report: cutBytes(this.d.report(u.
|
|
|
13610
13711
|
this.d.log({ event: "notice-post-failed", device: c.device, kind: notice.kind, error: String(error.message).slice(0, 200) });
|
|
13611
13712
|
}
|
|
13612
13713
|
}
|
|
13714
|
+
return true;
|
|
13613
13715
|
}
|
|
13614
13716
|
};
|
|
13615
13717
|
function runNotice(s2) {
|
|
13718
|
+
const scope = s2.device !== void 0 ? { device: s2.device, ...s2.team !== void 0 ? { team: s2.team } : {} } : {};
|
|
13616
13719
|
const who = s2.ally ? "An ally run" : "A run";
|
|
13617
|
-
if (s2.kind === "
|
|
13720
|
+
if (s2.kind === "revoked") return { key: `revoked/${s2.main}/${s2.ended ? "ended" : s2.manual ? "manual" : "pending"}`, notice: { kind: s2.ended ? "main-revoked" : "main-revoke-pending", unit: allyLabel(s2.label), text: s2.text }, scope };
|
|
13721
|
+
if (s2.kind === "refused") return { key: `refused/${s2.workspace}/${createHash12("sha256").update(s2.text).digest("hex").slice(0, 16)}`, notice: { kind: "run-refused", unit: allyLabel(s2.label), text: s2.text }, scope };
|
|
13618
13722
|
const trust = s2.detail?.startsWith("No trust decision");
|
|
13619
13723
|
const kind = trust ? "trust-unanswered" : s2.outcome === "quit" ? "run-quit" : s2.outcome === "timeout" ? "run-timeout" : "run-crashed";
|
|
13620
|
-
return { key: `run/${s2.run}`, notice: { kind, unit: allyLabel(s2.label), text: `${who} ${s2.outcome === "quit" ? "was quit" : s2.outcome === "timeout" ? "timed out" : "crashed"}${s2.detail ? `: ${s2.detail}` : ""}` } };
|
|
13724
|
+
return { key: `run/${s2.run}`, notice: { kind, unit: allyLabel(s2.label), text: `${who} ${s2.outcome === "quit" ? "was quit" : s2.outcome === "timeout" ? "timed out" : "crashed"}${s2.detail ? `: ${s2.detail}` : ""}` }, scope };
|
|
13621
13725
|
}
|
|
13622
13726
|
|
|
13623
13727
|
// src/server/towerrunstore.ts
|
|
@@ -15461,6 +15565,62 @@ function removeAnswer(slot, workspace, id, now) {
|
|
|
15461
15565
|
if (was.some((a) => a.id === id)) write(slot, workspace, ANSWERS, was.filter((a) => a.id !== id));
|
|
15462
15566
|
}
|
|
15463
15567
|
|
|
15568
|
+
// src/server/revoke.ts
|
|
15569
|
+
var GONE = /No live session is named|is not in this workspace|is not a main agent|was not opened by a tower/;
|
|
15570
|
+
function createRevoker(d) {
|
|
15571
|
+
const told = /* @__PURE__ */ new Set();
|
|
15572
|
+
let low = /* @__PURE__ */ new Set();
|
|
15573
|
+
let goneSeen = /* @__PURE__ */ new Map();
|
|
15574
|
+
let running;
|
|
15575
|
+
const pass = async () => {
|
|
15576
|
+
const now = /* @__PURE__ */ new Set(), gone = /* @__PURE__ */ new Map();
|
|
15577
|
+
for (const row2 of d.opened()) {
|
|
15578
|
+
if (row2.level !== void 0 && row2.level !== "dispatch") continue;
|
|
15579
|
+
if (!d.known(row2.team)) continue;
|
|
15580
|
+
const standing = d.standing(row2.device, row2.team);
|
|
15581
|
+
if (standing?.level === "dispatch") continue;
|
|
15582
|
+
now.add(row2.main);
|
|
15583
|
+
if (!low.has(row2.main)) continue;
|
|
15584
|
+
const why = standing ? "the device lost dispatch" : "the device left the team", who = `The main "${row2.name}" opened for ${row2.deviceLabel}`;
|
|
15585
|
+
try {
|
|
15586
|
+
await d.close(row2.workspace, row2.main);
|
|
15587
|
+
} catch (cause) {
|
|
15588
|
+
const reason = cause.message;
|
|
15589
|
+
if (GONE.test(reason)) {
|
|
15590
|
+
const seen = (goneSeen.get(row2.main) ?? 0) + 1;
|
|
15591
|
+
gone.set(row2.main, seen);
|
|
15592
|
+
if (seen < 2) continue;
|
|
15593
|
+
if (await d.paneGone(row2.workspace, row2.pane)) {
|
|
15594
|
+
d.log({ event: "revoke-gone", workspace: row2.workspace, main: row2.main });
|
|
15595
|
+
d.forget(row2.workspace, row2.main);
|
|
15596
|
+
gone.delete(row2.main);
|
|
15597
|
+
continue;
|
|
15598
|
+
}
|
|
15599
|
+
}
|
|
15600
|
+
const manual = /was not opened by a tower/.test(reason), key2 = `${row2.main}/${manual ? "manual" : "pending"}`;
|
|
15601
|
+
d.log({ event: "revoke-waiting", workspace: row2.workspace, main: row2.main, why });
|
|
15602
|
+
if (!told.has(key2)) {
|
|
15603
|
+
const sent = await d.signal({ kind: "revoked", workspace: row2.workspace, label: row2.label, ally: true, device: row2.device, ...row2.team ? { team: row2.team } : {}, main: row2.main, ended: false, ...manual ? { manual: true } : {}, text: manual ? `${who} still runs and cannot be ended by the box (${reason}). Close it by hand.` : `${who} could not be ended yet (${reason}). The box asks again until it quits.` });
|
|
15604
|
+
if (sent !== false) {
|
|
15605
|
+
told.add(key2);
|
|
15606
|
+
while (told.size > 256) told.delete(told.values().next().value);
|
|
15607
|
+
}
|
|
15608
|
+
}
|
|
15609
|
+
continue;
|
|
15610
|
+
}
|
|
15611
|
+
d.log({ event: "revoke-ended", workspace: row2.workspace, main: row2.main, why });
|
|
15612
|
+
d.forget(row2.workspace, row2.main);
|
|
15613
|
+
d.signal({ kind: "revoked", workspace: row2.workspace, label: row2.label, ally: true, device: row2.device, ...row2.team ? { team: row2.team } : {}, main: row2.main, ended: true, text: `${who} was ended: ${why}.` });
|
|
15614
|
+
}
|
|
15615
|
+
low = now;
|
|
15616
|
+
goneSeen = gone;
|
|
15617
|
+
};
|
|
15618
|
+
return {
|
|
15619
|
+
/** One look at every opened main; looks that overlap share the one in flight. A failure is logged and the next sweep tries again. */
|
|
15620
|
+
sweep: () => running ??= pass().catch((cause) => d.log({ event: "revoke-failed", error: cause.message.slice(0, 200) })).finally(() => running = void 0)
|
|
15621
|
+
};
|
|
15622
|
+
}
|
|
15623
|
+
|
|
15464
15624
|
// src/server/runs.ts
|
|
15465
15625
|
import { randomBytes as randomBytes17 } from "node:crypto";
|
|
15466
15626
|
import { lstatSync as lstatSync7, realpathSync as realpathSync10 } from "node:fs";
|
|
@@ -15991,7 +16151,7 @@ function createRuns2(o) {
|
|
|
15991
16151
|
release();
|
|
15992
16152
|
svc.refusedAt = now();
|
|
15993
16153
|
noticeOf(svc, { id: "settings", level: "error", kind: "settings", text: `A run could not start: ${cause.message}` });
|
|
15994
|
-
o.signal?.({ kind: "refused", workspace: svc.ref.workspace, label: svc.ref.record().label, ally: true, text: `A run could not start: ${cause.message}
|
|
16154
|
+
o.signal?.({ kind: "refused", workspace: svc.ref.workspace, label: svc.ref.record().label, ally: true, text: `A run could not start: ${cause.message}`, device, team: first2.team });
|
|
15995
16155
|
log.line("run-refused", { workspace: svc.ref.workspace, why: "settings", kind: "ally" });
|
|
15996
16156
|
return;
|
|
15997
16157
|
}
|
|
@@ -16202,7 +16362,7 @@ function createRuns2(o) {
|
|
|
16202
16362
|
a.rec.outcome = outcome;
|
|
16203
16363
|
a.rec.endedAt = iso2(now());
|
|
16204
16364
|
if (detail) a.rec.detail = phrase4(detail, 200);
|
|
16205
|
-
if (outcome === "crashed" || outcome === "timeout" || outcome === "quit") o.signal?.({ kind: "ended", run: a.rec.id, workspace: ws, label: svc.ref.record().label, ally: a.rec.kind === "ally", outcome, ...detail ? { detail: phrase4(detail, 200) } : {} });
|
|
16365
|
+
if (outcome === "crashed" || outcome === "timeout" || outcome === "quit") o.signal?.({ kind: "ended", run: a.rec.id, workspace: ws, label: svc.ref.record().label, ally: a.rec.kind === "ally", outcome, ...detail ? { detail: phrase4(detail, 200) } : {}, ...a.rec.kind === "ally" && a.rec.device ? { device: a.rec.device, ...a.rec.team ? { team: a.rec.team } : {} } : {} });
|
|
16206
16366
|
try {
|
|
16207
16367
|
writeRun(slot, a.rec);
|
|
16208
16368
|
pruneRuns(slot, ws);
|
|
@@ -16324,7 +16484,7 @@ function createRuns2(o) {
|
|
|
16324
16484
|
const orphan = a.rec.hand === true && !a.polled && a.pending === void 0 && a.closing === void 0;
|
|
16325
16485
|
const outcome = a.pending ?? (a.closing !== void 0 ? "done" : orphan ? "quit" : "crashed");
|
|
16326
16486
|
if (outcome === "crashed" && diedWithHerdr(a)) continue;
|
|
16327
|
-
await finish(a, outcome, outcome === "crashed" ? "pi exited without tower_done" : orphan ? "the pi left before its first poll" :
|
|
16487
|
+
await finish(a, outcome, outcome === "crashed" ? "pi exited without tower_done" : orphan ? "the pi left before its first poll" : a.why, a.left === true);
|
|
16328
16488
|
continue;
|
|
16329
16489
|
}
|
|
16330
16490
|
if (a.recovering !== void 0) {
|
|
@@ -16353,9 +16513,18 @@ function createRuns2(o) {
|
|
|
16353
16513
|
if (!a.token) continue;
|
|
16354
16514
|
if (a.rec.kind === "ally" && !standingOf(a)) {
|
|
16355
16515
|
log.line("ally-ended", { workspace: a.rec.workspace, run: a.rec.id, why: "the sender left the roster" });
|
|
16516
|
+
a.why = "the sender left the team";
|
|
16356
16517
|
terminate(a, "quit");
|
|
16357
16518
|
continue;
|
|
16358
16519
|
}
|
|
16520
|
+
const low = a.rec.kind === "ally" && a.rec.level === "dispatch" && o.ally?.known?.(a.rec.team ?? "") !== false && standingOf(a)?.level !== "dispatch";
|
|
16521
|
+
if (low && a.lowSeen) {
|
|
16522
|
+
log.line("ally-ended", { workspace: a.rec.workspace, run: a.rec.id, why: "the sender lost dispatch" });
|
|
16523
|
+
a.why = "the sender lost dispatch";
|
|
16524
|
+
terminate(a, "quit");
|
|
16525
|
+
continue;
|
|
16526
|
+
}
|
|
16527
|
+
a.lowSeen = low;
|
|
16359
16528
|
if (now() - a.lastPoll > t.lost) {
|
|
16360
16529
|
log.line("run-lost", { run: a.rec.id });
|
|
16361
16530
|
terminate(a, "crashed");
|
|
@@ -16526,6 +16695,7 @@ function createRuns2(o) {
|
|
|
16526
16695
|
a.offered.delete(event.id);
|
|
16527
16696
|
a.rec.events.push(event.id);
|
|
16528
16697
|
writeRun(slot, a.rec);
|
|
16698
|
+
if (a.rec.kind === "ally") o.ally?.delivered?.(event.id, a.rec.id);
|
|
16529
16699
|
if (event.kind === "boss") bossTurn(a);
|
|
16530
16700
|
return { ok: true };
|
|
16531
16701
|
}
|
|
@@ -16790,7 +16960,7 @@ function createRuns2(o) {
|
|
|
16790
16960
|
const a = of(token);
|
|
16791
16961
|
if (a && !a.ended && a.rec.kind === "ally") {
|
|
16792
16962
|
const standing = standingOf(a);
|
|
16793
|
-
if (!standing) terminate(a, "quit");
|
|
16963
|
+
if (!standing) a.why ??= "the sender left the team", terminate(a, "quit");
|
|
16794
16964
|
return { id: a.rec.id, workspace: a.rec.workspace, kind: "ally", ...standing ? { level: standing.level } : { gone: true }, ...a.rec.device ? { device: a.rec.device } : {}, ...a.rec.deviceLabel ? { deviceLabel: a.rec.deviceLabel } : {}, ...a.rec.deviceShort ? { deviceShort: a.rec.deviceShort } : {}, ...a.rec.team ? { team: a.rec.team } : {}, ...a.rec.teamName ? { teamName: a.rec.teamName } : {}, pane: a.rec.pane, asks: itemsOf(a, true).flatMap((i) => i.awaitsReply ? [i.awaitsReply] : []) };
|
|
16795
16965
|
}
|
|
16796
16966
|
return a && !a.ended ? { id: a.rec.id, workspace: a.rec.workspace, kind: headRun(a) ? "head" : a.rec.kind, ...a.rec.level ? { level: a.rec.level } : {}, ...a.rec.device ? { device: a.rec.device } : {}, ...a.rec.deviceLabel ? { deviceLabel: a.rec.deviceLabel } : {}, ...a.rec.team ? { team: a.rec.team } : {}, pane: a.rec.pane, asks: itemsOf(a, true).flatMap((i) => i.awaitsReply ? [i.awaitsReply] : []) } : void 0;
|
|
@@ -16911,6 +17081,8 @@ function createRuns2(o) {
|
|
|
16911
17081
|
parked: readQueue(slot, workspace, true).map((e) => ({ id: e.id, kind: e.kind, ...e.from?.name ?? e.from?.id ? { from: e.from?.name ?? e.from?.id } : {}, first: phrase4(e.text.split("\n").find((l) => l.trim()) ?? "", 120) }))
|
|
16912
17082
|
};
|
|
16913
17083
|
},
|
|
17084
|
+
/** The panes of the live ally runs, with the device each serves and its team: the units a controller dispatched (a question asked in one is that controller's). */
|
|
17085
|
+
allyPanes: () => [...services.values()].flatMap((svc) => [...svc.allies.values()].filter((a) => !a.ended && a.rec.pane && a.rec.device).map((a) => ({ pane: a.rec.pane, device: a.rec.device, ...a.rec.team ? { team: a.rec.team } : {} }))),
|
|
16914
17086
|
/** The waiting events of a workspace for the GUI's queue rows. */
|
|
16915
17087
|
queue: (workspace) => {
|
|
16916
17088
|
const s2 = find2(workspace);
|
|
@@ -17087,6 +17259,7 @@ var UNKNOWN = "(unknown)";
|
|
|
17087
17259
|
var RUN_SEND_CAP_MS = 2e4;
|
|
17088
17260
|
var RETRY_MS3 = [1e3, 2e3, 5e3, 1e4, 3e4];
|
|
17089
17261
|
var REPUBLISH_MS = 300;
|
|
17262
|
+
var REVOKE_MS = 15e3;
|
|
17090
17263
|
var REFRESH_MS2 = 4 * 6e4;
|
|
17091
17264
|
var RECOVER_MS = 6e4;
|
|
17092
17265
|
var RECOVER_POLL_MS = 2e3;
|
|
@@ -17216,10 +17389,14 @@ function createTowers(o) {
|
|
|
17216
17389
|
standing: (device, team) => o.ally.standing(device, team),
|
|
17217
17390
|
project: (team) => o.ally.project(team),
|
|
17218
17391
|
thread: (device) => o.ally.thread(device),
|
|
17392
|
+
known: (team) => o.ally.known?.(team) ?? true,
|
|
17219
17393
|
waiting: (identity2) => {
|
|
17220
17394
|
const ws = workspaceOf(identity2), answers2 = ws ? readAnswers(slot, ws, now()).map((a) => ({ id: `${LOCAL}${a.id}`, device: a.device, label: a.label, short: a.short, team: a.team, teamName: a.teamName, fromWorkspace: "", replyExpected: false, text: a.text, at: a.at, local: a.from, ...a.ask ? { awaitsReply: a.ask } : {} })) : [];
|
|
17221
17395
|
return [...o.ally.waiting(identity2), ...answers2].sort((a, b) => a.at - b.at);
|
|
17222
17396
|
},
|
|
17397
|
+
delivered: (id, run4) => {
|
|
17398
|
+
if (!id.startsWith(LOCAL)) o.ally.delivered?.(id, run4);
|
|
17399
|
+
},
|
|
17223
17400
|
taken: async (id, identity2) => {
|
|
17224
17401
|
const ws = workspaceOf(identity2);
|
|
17225
17402
|
if (!id.startsWith(LOCAL)) return o.ally.taken(id, identity2);
|
|
@@ -17691,6 +17868,7 @@ function createTowers(o) {
|
|
|
17691
17868
|
error("purge", cause.message);
|
|
17692
17869
|
}
|
|
17693
17870
|
}, o.refreshMs ?? REFRESH_MS2));
|
|
17871
|
+
timers2.push(setInterval(() => void revoker.sweep(), o.revokeMs ?? REVOKE_MS));
|
|
17694
17872
|
const poll = o.recoverPollMs ?? RECOVER_POLL_MS;
|
|
17695
17873
|
timers2.push(setInterval(() => {
|
|
17696
17874
|
if (now() < recoverUntil + poll) look2();
|
|
@@ -17856,6 +18034,25 @@ function createTowers(o) {
|
|
|
17856
18034
|
}
|
|
17857
18035
|
return closed;
|
|
17858
18036
|
};
|
|
18037
|
+
const revoker = createRevoker({
|
|
18038
|
+
opened: () => o.ally ? [...services.values()].filter((l) => !l.stopped && l.state === "live" && l.control && !isHeadL(l)).flatMap((l) => readRoutes(slot, l.record.workspace, now()).filter((r) => r.kind === "opened").map((r) => ({ workspace: l.record.workspace, label: l.record.label, main: r.to, name: r.name, device: r.device, deviceLabel: r.label, team: r.team, ...r.level ? { level: r.level } : {}, ...r.pane ? { pane: r.pane } : {} }))) : [],
|
|
18039
|
+
known: (team) => o.ally?.known?.(team) ?? true,
|
|
18040
|
+
standing: (device, team) => o.ally?.standing(device, team),
|
|
18041
|
+
close: async (workspace, main) => void await need(workspace).control.closeMain(main),
|
|
18042
|
+
paneGone: async (workspace, pane) => {
|
|
18043
|
+
if (!pane) return false;
|
|
18044
|
+
try {
|
|
18045
|
+
const net = await need(workspace).reach.read();
|
|
18046
|
+
return !net.ps.some((p) => p.pane_id === pane);
|
|
18047
|
+
} catch {
|
|
18048
|
+
return false;
|
|
18049
|
+
}
|
|
18050
|
+
},
|
|
18051
|
+
// Herdr's listing is the second witness that a main is gone; no pane or a failed read confirms nothing
|
|
18052
|
+
forget: (workspace, main) => void forgetClosed(workspace, { main }),
|
|
18053
|
+
signal: (s2) => o.runs?.signal?.(s2),
|
|
18054
|
+
log: ({ event, ...rest }) => log.line(String(event), rest)
|
|
18055
|
+
});
|
|
17859
18056
|
const answered = answeredAsks(now);
|
|
17860
18057
|
const runToolFor = async (request) => {
|
|
17861
18058
|
const who = runs2.runOf(request.token);
|
|
@@ -17865,7 +18062,7 @@ function createTowers(o) {
|
|
|
17865
18062
|
const peer = who.kind === "ally" && who.device && who.team ? { device: who.device, label: who.deviceLabel ?? who.device.slice(0, 6), short: who.deviceShort ?? who.device.slice(0, 6), team: who.team, teamName: who.teamName ?? who.team.slice(0, 6) } : void 0;
|
|
17866
18063
|
const route = (to, name3, kind, ask, live, panes3, pane) => {
|
|
17867
18064
|
try {
|
|
17868
|
-
addRoute(slot, ws, { ...peer, to, name: name3, kind, at: now(), ...ask ? { ask } : {}, ...pane ? { pane } : {} }, now(), live, panes3);
|
|
18065
|
+
addRoute(slot, ws, { ...peer, to, name: name3, kind, at: now(), ...ask ? { ask } : {}, ...pane ? { pane } : {}, ...kind === "opened" && who.level ? { level: who.level } : {} }, now(), live, panes3);
|
|
17869
18066
|
} catch (cause) {
|
|
17870
18067
|
error("ally-route", cause.message);
|
|
17871
18068
|
}
|
|
@@ -18627,9 +18824,9 @@ async function runServer(o) {
|
|
|
18627
18824
|
...o.towers,
|
|
18628
18825
|
// A seam that watches the runs (tests) sees every signal too, and does not take the place of the notice to the controllers.
|
|
18629
18826
|
runs: { ...o.towers?.runs, signal: (sig) => {
|
|
18630
|
-
const r = runNotice(sig);
|
|
18631
|
-
void reporter?.notify(r.key, r.notice);
|
|
18827
|
+
const r = runNotice(sig), sent = reporter?.notify(r.key, r.notice, r.scope);
|
|
18632
18828
|
o.towers?.runs?.signal?.(sig);
|
|
18829
|
+
return sent;
|
|
18633
18830
|
} }
|
|
18634
18831
|
});
|
|
18635
18832
|
stops.push(cfg.watch(() => {
|
|
@@ -18648,7 +18845,10 @@ async function runServer(o) {
|
|
|
18648
18845
|
}
|
|
18649
18846
|
}), o.herdrPollMs ?? HERDR_POLL_MS));
|
|
18650
18847
|
const dialogsOut = () => forwarder ??= new DialogForwarder({
|
|
18651
|
-
|
|
18848
|
+
hub: guiHost.dialogs().hub,
|
|
18849
|
+
read: guiHost.dialogs().read,
|
|
18850
|
+
dispatcher: dispatcherLookup({ paneOf: (key2) => guiHost.dialogs().paneOf(key2), list: () => panes(cfg.run), allyPanes: () => towers?.runs.allyPanes() ?? [], now }),
|
|
18851
|
+
admin: (team, device) => link?.holder?.isAdmin(team, device) ?? false,
|
|
18652
18852
|
controllers: () => link?.holder?.dialogControllers() ?? [],
|
|
18653
18853
|
online: (team, device) => link?.holder?.deviceOnline(team, device) ?? false,
|
|
18654
18854
|
controllersKnown: () => link?.holder?.controllersKnown() ?? false,
|
|
@@ -18658,11 +18858,12 @@ async function runServer(o) {
|
|
|
18658
18858
|
},
|
|
18659
18859
|
log: ({ event, ...rest }) => log.line(String(event), Object.fromEntries(Object.entries(rest).map(([k, v]) => [k, typeof v === "number" || typeof v === "boolean" ? v : phrase6(v, 200)]))),
|
|
18660
18860
|
now,
|
|
18661
|
-
notice: (key2, notice) => unitsOut().notify(key2, notice)
|
|
18861
|
+
notice: (key2, notice, scope) => unitsOut().notify(key2, notice, scope)
|
|
18662
18862
|
});
|
|
18663
18863
|
const unitWorkspaces = /* @__PURE__ */ new Map();
|
|
18664
18864
|
const unitsOut = () => reporter ??= new UnitReporter({
|
|
18665
18865
|
controllers: () => link?.holder?.dialogControllers() ?? [],
|
|
18866
|
+
admin: (team, device) => link?.holder?.isAdmin(team, device) ?? false,
|
|
18666
18867
|
// Per team: the units whose workspace has a pane inside that team's project, when its policy shares status. Labels and monitor commands leave this computer, so they pass the same scrub as a board's.
|
|
18667
18868
|
units: async (team) => {
|
|
18668
18869
|
const policy = link?.holder?.policyOf(team), rows = towers?.status() ?? [];
|
|
@@ -18714,11 +18915,11 @@ ${unit}`), note = ws ? readNotes(slot, ws).at(-1) : void 0, sent = ws ? link?.ho
|
|
|
18714
18915
|
asks: { take: (q) => asks.take(q) },
|
|
18715
18916
|
served: (identity2) => towers?.allyWaiting(identity2),
|
|
18716
18917
|
confirmed: (to, dialogId, allow) => towers?.runs.confirmed(dialogId, allow) ?? false,
|
|
18717
|
-
openTower: async (project, toWorkspace) => {
|
|
18918
|
+
openTower: async (project, toWorkspace, from) => {
|
|
18718
18919
|
try {
|
|
18719
18920
|
await openTower({ run: cfg.run, towers: () => towers, now, home: o.link?.home ?? homedir12(), agentDir: env.PI_CODING_AGENT_DIR ?? join44(o.link?.home ?? homedir12(), ".pi", "agent") }, project, toWorkspace);
|
|
18720
18921
|
} catch (error) {
|
|
18721
|
-
void unitsOut().notify(`tower-open/${project}/${Math.floor(now() / 6e5)}`, { kind: "tower-open-failed", unit: allyLabel(project), text: `Could not open a tower for ${project}: ${error.message}` });
|
|
18922
|
+
void unitsOut().notify(`tower-open/${from}/${project}/${Math.floor(now() / 6e5)}`, { kind: "tower-open-failed", unit: allyLabel(project), text: `Could not open a tower for ${project}: ${error.message}` }, { device: from });
|
|
18722
18923
|
throw error;
|
|
18723
18924
|
}
|
|
18724
18925
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-straw-boss",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.0",
|
|
4
4
|
"description": "Straw Boss coordination for pi in Herdr: sessions join a tab's net by choice, the first joiner is its main agent, a workspace tower opens on demand with a live monitoring dashboard, and boss_broadcast reaches the rest",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/prompts/apps-config.md
CHANGED
|
@@ -4,7 +4,7 @@ The project's managed apps live in `.straw-boss/apps.json` at the git root, or i
|
|
|
4
4
|
|
|
5
5
|
```json
|
|
6
6
|
{"integration": {"skill": "integrate", "maxCars": 4, "maxWaitMin": 60},
|
|
7
|
-
"apps": [{"name": "api", "dir": "apps/api", "match": ["backend", "rest api"], "integration": {"base": "develop"}}]}
|
|
7
|
+
"apps": [{"name": "api", "dir": "apps/api", "match": ["backend", "rest api"], "integration": {"base": "develop", "stages": ["merge", "deploy", "uat"], "targets": {"test-env": "auto"}}}]}
|
|
8
8
|
```
|
|
9
9
|
|
|
10
10
|
| Field | Meaning |
|
|
@@ -16,7 +16,7 @@ The project's managed apps live in `.straw-boss/apps.json` at the git root, or i
|
|
|
16
16
|
| `localFiles` | Gitignored files a new worktree needs, each with `path` relative to `dir`; copy them before launch. A `sensitive: true` file is copied only under the user's authorization, and an entry without `optional: true` that is missing blocks the launch. |
|
|
17
17
|
| `crossAppSkills` | `withApp` and `skill`: an existing skill that handles this app depending on `withApp`; name it in the brief. |
|
|
18
18
|
| `integration` (top level) | The project's merge train defaults: `skill` is the name of the project skill that holds the concrete checks (a name, as `gitWorkflowSkill` is), `maxCars` (default 4) and `maxWaitMin` (default 60) say when a train departs; the skill may override them. |
|
|
19
|
-
| `integration` (per app) | `base` is the integration branch; `skill` overrides the project's. Only an app with this field takes the merge train; an app without it keeps today's flow, and the main merges after its own checks. |
|
|
19
|
+
| `integration` (per app) | `base` is the integration branch; `skill` overrides the project's; `stages` lists, in order, what the integrator carries each train through: `merge` (the default, always first), then optionally `deploy`, then `uat` (only after `deploy`); `targets` maps a deploy target to `auto` (every train deploys there with no per-car authorization: the boss's standing word, so only the boss sets it: a main adds or widens an `auto` entry only with the boss's words for it quoted in the commit message) or `strict` (only cars whose slip `auth` names it; the default for an unlisted target). Only an app with this field takes the merge train; an app without it keeps today's flow, and the main merges after its own checks. |
|
|
20
20
|
|
|
21
21
|
## Merge train
|
|
22
22
|
|
|
@@ -31,7 +31,10 @@ The skill is found by the headings below, exactly as written. Straw-boss reads n
|
|
|
31
31
|
| `## Full gates` | integrator | The heavy checks, which depend on the clock (run at low load), and which failures may be rerun as flakes. |
|
|
32
32
|
| `## Merge` | integrator | How the train enters the base branch. |
|
|
33
33
|
| `## Post-merge` | integrator | What to watch after the merge, how to confirm the release, who closes tickets. |
|
|
34
|
+
| `## Deploy` | integrator | Stage `deploy`: how the train reaches each target, in what order, what a target needs beyond the slip's auth, and how a failed one is rolled back. |
|
|
35
|
+
| `## Deploy verify` | integrator | Stage `deploy`: what shows a target runs the train's own version. |
|
|
36
|
+
| `## UAT` | coordinator, integrator | Stage `uat`: which cars carry checkpoints, where and how the deployed result is captured and by whom, and whether a car the boss comments on is reverted before its fix. |
|
|
34
37
|
| `## Urgent lane` | integrator | Optional: when a car may jump the queue, and whether it may interrupt a running gate. |
|
|
35
38
|
| `## Load` | integrator | Optional: the project's lock name and load thresholds. |
|
|
36
39
|
|
|
37
|
-
`Light checks`, `Full gates`, and `Merge` are required. If one is missing, the integrator does not depart and calls the tower with `why=blocked`; it never guesses a section.
|
|
40
|
+
`Light checks`, `Full gates`, and `Merge` are required, and so is each heading of a stage an app declares. If one is missing, the integrator does not depart and calls the tower with `why=blocked`; it never guesses a section.
|
package/prompts/integrator.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Integrator
|
|
2
2
|
|
|
3
|
-
You are the integrator of one project: the single main that
|
|
3
|
+
You are the integrator of one project: the single main that takes its coordinators' cars, for each app with an `integration` field (apps-config.md beside this file), through the stages the app's `stages` list in order: `merge` into its integration branch, then `deploy`, then `uat`. You are a main, not the tower. The project's skill (apps-config `integration.skill`) holds every concrete check: read its headings `Light checks`, `Train assembly`, `Full gates`, `Merge`, `Post-merge`, `Deploy`, `Deploy verify`, `UAT`, `Urgent lane`, `Load`; add nothing it does not say. If `Light checks`, `Full gates`, or `Merge` is missing, or a declared stage lacks its own (`deploy`: `Deploy` and `Deploy verify`; `uat`: `UAT`), do not depart: `call_tower` with `why=blocked` and name the config error.
|
|
4
4
|
|
|
5
5
|
## Rules
|
|
6
6
|
|
|
7
|
-
- You cannot create the boss's authorization: merge nothing whose car lacks a quote of one that covers the integration branch.
|
|
8
|
-
- Only you merge into the integration branch and claim `pre-dev/<code>`. Your own merges and
|
|
9
|
-
- Name: `boss_rename` yourself `integ <project>` (16 characters at most: shorten the project) and never rename again. Keep a ledger at `~/.pi/agent/straw-boss/trains/<project>/<app>.json` (car, tickets, SHAs, stage) and one todo per train.
|
|
7
|
+
- You cannot create the boss's authorization: merge nothing whose car lacks a quote of one that covers the integration branch. A deploy target is `auto` or `strict` by the app's `integration.targets` (unlisted: `strict`): an `auto` target takes every train, the config being the boss's standing word; a `strict` one only cars whose `auth` names it.
|
|
8
|
+
- Only you merge into the integration branch and claim `pre-dev/<code>`. Your own merges, pushes, and merge-triggered deploys need no `shared` call: the slips' auth covers them. A deploy you start yourself still calls `shared` naming the target, so the tower can see a clash.
|
|
9
|
+
- Name: `boss_rename` yourself `integ <project>` (16 characters at most: shorten the project) and never rename again. Keep a ledger at `~/.pi/agent/straw-boss/trains/<project>/<app>.json` (car, tickets, SHAs, stage, deployed versions per target, UAT verdicts) and one todo per train.
|
|
10
10
|
|
|
11
11
|
## Receiving a car
|
|
12
12
|
|
|
@@ -16,19 +16,28 @@ A car slip is one block, and one missing field returns it:
|
|
|
16
16
|
car: <app> | tickets: <ids> | branch: <name> | head: <sha40> | tested-on: <base sha>
|
|
17
17
|
review: P0=0 P1=0 (<report path>) | light: pass (<commands and result per Light checks>)
|
|
18
18
|
migrations: <names or none> (applied to a DB: no|<which>) | lane: normal|urgent (<ground for urgent>)
|
|
19
|
+
deploy: <targets or none> | uat: <checkpoints path or none>
|
|
19
20
|
auth: "<the boss's words>" | coordinator: <session id>
|
|
20
21
|
```
|
|
21
22
|
|
|
22
|
-
Check: `head` is still the branch tip, `auth` covers the integration branch
|
|
23
|
+
Check: `head` is still the branch tip, `auth` covers the integration branch and every `strict` target in `deploy`, `review` has a report path, a `uat` file lists per checkpoint its flow, state, steps, and expected result and comes with a target (`deploy` or an `auto` one), and no migration is applied to a DB (the rebase tool cannot regenerate it): else return the car to its coordinator. Answer the coordinator with the train number and departure conditions.
|
|
23
24
|
|
|
24
25
|
## Departure and assembly
|
|
25
26
|
|
|
26
27
|
- Depart when the train reaches `maxCars` (default 4), or its oldest car has waited `maxWaitMin` (default 60), or every coordinator awaited has arrived; the skill's `Load` allows it (if omitted: wait while the 1-minute load exceeds the core count); and the integration branch's pipeline is not red.
|
|
27
28
|
- Assemble on a local `train/<code>` from the latest base, in a worktree at `~/.herdr/worktrees/<repo>/train-<code>`: `merge --no-ff` each car in arrival order. Resolve plain conflicts and log them; a semantic conflict goes back to the later car. Run `Train assembly`.
|
|
28
|
-
- A fresh-context `reviewer` reviews `train/<code>` against the base for interactions between cars, conflict resolutions, and migration order. P0 or P1 of one car goes back to it; of assembly, you fix it. Then run `Full gates` once per train. Green: `Merge`. Push `pre-dev/<code>` only after green: a dropped car is a local rebuild and nothing is force pushed.
|
|
29
|
+
- A fresh-context `reviewer` reviews `train/<code>` against the base for interactions between cars, conflict resolutions, and migration order. P0 or P1 of one car goes back to it; of assembly, you fix it. Then run `Full gates` once per train. Green: `Merge`, then `Post-merge`. Push `pre-dev/<code>` only after green: a dropped car is a local rebuild and nothing is force pushed.
|
|
29
30
|
- Red: the skill's `Full gates` says how to find the guilty car. Drop it, rebuild from the rest. Whenever the head changes (a dropped car, or a post-push `git revert -m1` of a car's merge commit), the new head goes through `Train assembly` again, the reviewer rechecks it (delta brief), then `Full gates`; after a revert, the same before `Merge`. Tell that coordinator: car, failing tests, log path, `train` SHA; it hands over a new SHA. If its session is gone, `call_tower` with `why=blocked`.
|
|
30
|
-
- `urgent` is only the boss's explicit "first" or a P0
|
|
31
|
+
- `urgent` is only the boss's explicit "first" or a fix of a P0 defect in a live deploy target; it still gets review and gates.
|
|
31
32
|
|
|
32
|
-
##
|
|
33
|
+
## Deploy
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
Stage `deploy`, after `Post-merge`: run `Deploy` for each `auto` target and each target the slips name, in the order the skill gives. A `strict` target takes the train only when every car in it has `auth` naming it (a deploy ships every car); else skip it and ask the boss with your question tool whether to deploy it or hold those cars; a held car is not landed; held before `Merge`, it leaves the train as a dropped car does. Where merging deploys a `strict` target, ask before `Merge`. One train at a time per target: a later train waits for the earlier one's `Deploy verify`, and where merging deploys a target (the skill says), the next train's `Merge` waits for it too. First do what the skill adds per target, then run `Deploy verify` per target: done is the target running the train's own version, as the skill shows it; a green job, an accepted request, or a finished monitor is not done. A failed target follows the skill's `Deploy` (rollback, retry); its car goes back as a red one does.
|
|
36
|
+
|
|
37
|
+
## UAT
|
|
38
|
+
|
|
39
|
+
Stage `uat`, after `Deploy verify`: run `UAT` for the cars with a `uat` file, on the deployed result. One page per train, one `review` block: an item per checkpoint, `label` `<ticket>: <flow>, <state>`, `body` its steps and expected result. Open it with `show_doc` (`view: "page"`) once ready; a blank box means accepted. A later train may deploy meanwhile; captures are the record. A comment returns its car: send its coordinator comments, captures, version; `UAT` says whether to revert first; a revert on the base goes as its own train through review, `Full gates`, `Merge`, and `Deploy`. The fix comes back as a new car whose commented checkpoints are captured again, or all of its checkpoints after a revert.
|
|
40
|
+
|
|
41
|
+
## After the train
|
|
42
|
+
|
|
43
|
+
When a car has no stage left, tell its coordinator by intercom "landed in <version>, your commit is <sha>", adding "accepted in UAT" for a `uat` car; it closes its ticket (you, only if its session is gone). When the queue is empty, no stage runs, and no UAT page waits, `call_tower` with `why=milestone`, reply false, saying idle; the tower closes you.
|