clearotron 0.3.0-beta.1 → 0.3.0-beta.2

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 (63) hide show
  1. package/.env.example +13 -2
  2. package/CONTRIBUTING.md +1 -1
  3. package/INSTALL.md +12 -10
  4. package/README.md +3 -2
  5. package/bin/brandowner.mjs +94 -1
  6. package/bin/onboard.mjs +156 -55
  7. package/bin/passphrase.mjs +23 -4
  8. package/bin/start.mjs +177 -38
  9. package/build-info.json +2 -2
  10. package/docs/architecture/04-configuration-reference.md +5 -2
  11. package/docs/architecture/05-config-governance.md +3 -2
  12. package/driver/CHANGELOG.md +95 -0
  13. package/driver/demo-posture.mjs +59 -8
  14. package/driver/driver.config.mjs +36 -7
  15. package/driver/engine/child-record.mjs +93 -0
  16. package/driver/findings-model.mjs +6 -1
  17. package/driver/knockout-assess-record.mjs +6 -3
  18. package/driver/package.json +1 -1
  19. package/driver/portal-local-auth.mjs +98 -3
  20. package/driver/portal-service.mjs +65 -29
  21. package/driver/publish/knockout.mjs +12 -1
  22. package/driver/publish/office-record-links.mjs +61 -10
  23. package/driver/publish/render-knockout.mjs +31 -11
  24. package/driver/publish/xlsx.mjs +33 -1
  25. package/driver/register-records.mjs +6 -0
  26. package/driver/run-requirements.mjs +24 -1
  27. package/driver/runner.mjs +10 -5
  28. package/driver/skills/knockout-assess/SKILL.md +1 -1
  29. package/driver/suite-census.json +174 -30
  30. package/driver/unit-inventory.mjs +109 -5
  31. package/driver/updater-identity.mjs +178 -0
  32. package/driver/usage-ledger.mjs +5 -5
  33. package/mcp-server/CHANGELOG.md +6 -0
  34. package/mcp-server/http-server.mjs +16 -13
  35. package/mcp-server/lib/driver.mjs +7 -0
  36. package/mcp-server/lib/knockout.mjs +14 -2
  37. package/mcp-server/lib/ops.mjs +38 -16
  38. package/mcp-server/package.json +2 -2
  39. package/mcp-server/server.mjs +1 -1
  40. package/package.json +1 -1
  41. package/portal-ui/dist/assets/{index-CWTHP0sH.js → index-CcFjgM78.js} +32 -17
  42. package/portal-ui/dist/assets/{index-KpytsmNH.css → index-CsCuPshD.css} +7 -2
  43. package/portal-ui/dist/index.html +2 -2
  44. package/portal-ui/package.json +3 -3
  45. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  46. package/providers/oauth-mcp-bridge/package.json +2 -2
  47. package/scripts/changelog-plain-language.mjs +7 -30
  48. package/scripts/e2e.mjs +13 -2
  49. package/scripts/env-audit.mjs +10 -3
  50. package/scripts/env-classify.mjs +205 -12
  51. package/scripts/live-surface-check.mjs +74 -2
  52. package/scripts/plain-language-rules.mjs +103 -0
  53. package/scripts/release-note-required.mjs +118 -24
  54. package/scripts/release-notes-lint.mjs +22 -43
  55. package/scripts/release-version.mjs +59 -0
  56. package/scripts/revisit-render-check.mjs +6 -2
  57. package/scripts/text-difference.mjs +22 -0
  58. package/shared/env-local.mjs +25 -2
  59. package/shared/names-in-force.mjs +1 -0
  60. package/shared/reap-on-exit.mjs +27 -14
  61. package/shared/store-in-repo.mjs +147 -0
  62. package/shared/withheld-paths-access.mjs +6 -6
  63. package/shared/yes-no-echo.mjs +35 -0
@@ -0,0 +1,178 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ // updater-identity.mjs — WHICH COPY OF THE UPDATER IS ACTUALLY DEPLOYING THIS BOX.
4
+ //
5
+ // Deploy health attributes a service to a commit by resolving a tree from the unit's WorkingDirectory
6
+ // and from `/proc/<pid>/cmdline`. Both halves fail for exactly one unit, and it is the one that does the
7
+ // deploying: the updater is a `oneshot` that runs from a home directory with no checkout under it, and
8
+ // its command line is the update script rather than the product entry point. So the unit that places
9
+ // every commit on this box was the one unit the check reported as "could NOT be read and was NOT
10
+ // compared" — an updater running from a stale copy would place a stale tree, and nothing would notice.
11
+ //
12
+ // THE UPDATER STAMPS ITSELF, the way the drainer already does, and this reads that stamp. The two shapes
13
+ // are deliberately the same: a best-effort write at the top of a tick, a verdict at the reading end, and
14
+ // an absent stamp treated as a failure rather than a skip.
15
+ //
16
+ // AN ABSENT STAMP IS THE LOUD CASE, and that is the whole point rather than a severity preference. The
17
+ // stamp writer shipped in the updater itself, so a copy of the updater old enough to predate it writes
18
+ // no stamp at all — which is precisely "the updater is running from a stale copy", the condition this
19
+ // exists to catch. Recording that as "not probed" would pass the exact box the check is for.
20
+ //
21
+ // WHAT THE SHA PAIR PROVES, AND WHAT IT DOES NOT. The stamp records the running copy's digest and the
22
+ // digest of the master it was taken from, and those two agreeing means the running copy matches the
23
+ // master AS THAT BOX CAN SEE IT. The master is read from a local store that nothing pulls
24
+ // automatically, so a store nobody has fetched leaves both sides equally old and equally in agreement.
25
+ // That case is real and this reader cannot see it; the pass message says so rather than claiming the
26
+ // updater is current, and the updater's own tick reports the store's age on the line above.
27
+
28
+ import { readFileSync } from "node:fs";
29
+ import { join } from "node:path";
30
+
31
+ export const UPDATER_STAMP_BASENAME = "_updater-identity.json";
32
+
33
+ /**
34
+ * Where the updater stamps itself: beside the update script, in the deploy directory it runs from.
35
+ *
36
+ * NEVER A HARDCODED PATH. The obvious spelling here is the service account's own home, and it is wrong
37
+ * twice over: it publishes a login on a public surface, and it disagrees with any box that points the
38
+ * updater somewhere else — which would red a healthy deployment forever, for a reason no message names.
39
+ * The caller passes the directory it derived from the unit itself, and the environment overrides both.
40
+ */
41
+ export function updaterStampPath(deployDir) { return join(deployDir, UPDATER_STAMP_BASENAME); }
42
+
43
+ /**
44
+ * The one path the reader and the writer both resolve, from the one name the writer already honours.
45
+ *
46
+ * THE READER MUST NOT INVENT A SECOND ENV NAME. A reader-only variable is a seam: a box that redirects
47
+ * its updater sets the writer's name, the reader keeps looking at the default, finds nothing, and
48
+ * reports "no stamp" — the loud branch — about a deployment that is stamping correctly. One name, read
49
+ * as the writer writes it: a FULL PATH to the stamp file, not a directory.
50
+ *
51
+ * @param {string|null} deployDir where the updater runs, derived from the unit; used only as fallback
52
+ * @param {object} [env] process.env, injectable so a test can drive both branches
53
+ */
54
+ export function resolveUpdaterStampPath(deployDir, env = process.env) {
55
+ const named = env?.CLEAROTRON_UPDATER_STAMP || null;
56
+ if (named) return named;
57
+ return deployDir ? updaterStampPath(deployDir) : null;
58
+ }
59
+
60
+ /**
61
+ * PURE. Why this box has no updater to judge, or null when it has one.
62
+ *
63
+ * A box with no updater unit (production today, and any install that nothing keeps current) answers
64
+ * `systemctl show` for it with `LoadState=not-found`, an empty WorkingDirectory and no process, and it
65
+ * arrives as an idle row. Judging that row fails on "where it stamps could not be resolved", a red that
66
+ * nothing on that box can clear. So an absent unit is a skip that says why, unless the environment
67
+ * redirects the stamp, which says an updater exists somewhere and is to be judged. No row at all is a
68
+ * could-not-look, and is left to the check that says so.
69
+ *
70
+ * @param {object|undefined} row the updater's row from the unit probe
71
+ * @param {object} [env] process.env, injectable so a test can drive the redirect
72
+ */
73
+ export function updaterAbsentHere(row, env = process.env) {
74
+ if (!row || row.load !== "not-found") return null;
75
+ if (resolveUpdaterStampPath(null, env)) return null;
76
+ return "this box has no updater unit, so nothing deploys it on a schedule and there is no running copy to compare — NOT PROBED";
77
+ }
78
+
79
+ /** The stamp, or null when there is none to read. A parse failure is a null — the verdict says so. */
80
+ export function readUpdaterStamp(deployDir, { read = null, env = process.env } = {}) {
81
+ const rd = read ?? ((p) => readFileSync(p, "utf8"));
82
+ const path = resolveUpdaterStampPath(deployDir, env);
83
+ if (!path) return null;
84
+ try { return JSON.parse(rd(path)); } catch { return null; }
85
+ }
86
+
87
+ /** Eight characters is enough to name a commit in a message, and null stays visibly null. */
88
+ const short = (c) => (c ? String(c).slice(0, 8) : "(unknown)");
89
+
90
+ /**
91
+ * PURE. What deploy health should say about the copy of the updater that is deploying this box.
92
+ *
93
+ * @param {object} o
94
+ * @param {object|null} o.stamp readUpdaterStamp()'s answer
95
+ * @param {number|null} o.now epoch seconds, for the stamp's age
96
+ * @param {string|null} o.deployClone the git toplevel this deploy is checking
97
+ * @param {number} [o.maxAgeSeconds] how old a stamp may be before the updater counts as not running
98
+ * @returns {{state:"pass"|"fail", message:string}}
99
+ *
100
+ * THE ALLOWANCE IS THREE HOURS AGAINST AN HOURLY TICK, and it is a judgement rather than a measurement,
101
+ * so it is written down with its reasoning. The stamp is written at the top of every run, so on the
102
+ * cadence the deployment keeps, one missed tick is an hour. It is not one hour here because a tick can
103
+ * legitimately defer — the updater exits early while a clearance is queued, claimed or in flight — and
104
+ * an arm that fired on a single deferral would red a box that is busy rather than broken. Three hours
105
+ * is two consecutive misses: past any single deferral, and still inside the window where somebody would
106
+ * want to know. Six hours was the first number written and it is five missed ticks, most of a working
107
+ * day, which is slack a one-hour cadence does not need.
108
+ */
109
+ export function updaterVerdict({ stamp, now = null, deployClone = null, maxAgeSeconds = 3 * 3600 } = {}) {
110
+ if (!stamp) {
111
+ return { state: "fail", message:
112
+ `no updater identity stamp at ${UPDATER_STAMP_BASENAME}: the copy of the updater that deploys this `
113
+ + "box did NOT say what it is, so whether it is the current one was not established. A copy old "
114
+ + "enough to predate the stamp writes none, which is itself the stale-updater case. This is a "
115
+ + "failure to look, never a pass." };
116
+ }
117
+
118
+ // — AN UNREADABLE SIDE IS NOT A MISMATCH. A digest that could not be taken arrives as null, and
119
+ // calling that "the two differ" would report drift invented out of a failure to read.
120
+ const wsha = stamp.wrapperSha256 ?? null;
121
+ const msha = stamp.masterSha256 ?? null;
122
+ if (!wsha || !msha) {
123
+ // The clause is written out per case rather than interpolated into one sentence: the single-sided
124
+ // wording needs the negation and the both-sided wording carries it in "neither", so the one shared
125
+ // sentence read "the master could be read as a digest" — the opposite of what happened, past an arm
126
+ // that matched the phrase and never read the verb.
127
+ const which = !wsha && !msha ? "neither the running copy of the updater nor its master could be read as a digest"
128
+ : !wsha ? "the running copy of the updater could NOT be read as a digest"
129
+ : "the master could NOT be read as a digest";
130
+ const why = stamp.masterCommitError ? ` The updater reported: ${stamp.masterCommitError}` : "";
131
+ return { state: "fail", message:
132
+ `the updater stamp is present but ${which}, so the running copy was NOT `
133
+ + `compared against its master.${why} This is a failure to look, never a pass.` };
134
+ }
135
+
136
+ // — THE AGE IS ALWAYS PRINTED AND RARELY JUDGED. A stopped deploy timer already fails on its own arm,
137
+ // and a second red for one condition teaches a reader to discount both. This fires only when the stamp
138
+ // is old enough that the updater cannot be running on any schedule the box could be keeping.
139
+ const writtenAt = Number(stamp.writtenAt) || 0;
140
+ const ageSeconds = now && writtenAt ? Math.max(0, now - writtenAt) : null;
141
+ const ageNote = ageSeconds === null
142
+ ? " (the stamp carries no readable time, so its age is unknown)"
143
+ : ` (stamped ${Math.floor(ageSeconds / 60)} min ago)`;
144
+
145
+ if (wsha !== msha) {
146
+ return { state: "fail", message:
147
+ `the running copy of the updater DIFFERS from its master${ageNote}: the copy that ran digests `
148
+ + `${short(wsha)} and the master it was taken from digests ${short(msha)}. This box is being `
149
+ + "deployed by an updater that is not the one in the store — reported, not reconciled." };
150
+ }
151
+
152
+ if (ageSeconds !== null && ageSeconds > maxAgeSeconds) {
153
+ return { state: "fail", message:
154
+ `the updater last stamped itself ${Math.floor(ageSeconds / 3600)}h ago${
155
+ maxAgeSeconds ? `, past the ${Math.floor(maxAgeSeconds / 3600)}h this check allows` : ""}. `
156
+ + "The stamp is written at the top of every tick, so a stamp this old means the updater has not "
157
+ + "run — whatever the box is serving, it is not tracking the branch." };
158
+ }
159
+
160
+ // — IS THIS UPDATER DEPLOYING THE TREE BEING CHECKED? The stamp records the checkout it places
161
+ // commits into. A box whose health check reads one clone while its updater feeds another is the
162
+ // two-clone straddle with the deploying mechanism on the far side of it, and the digests above agree
163
+ // in that state exactly as they do in a healthy one.
164
+ const norm = (p) => String(p ?? "").replace(/\/+$/, "");
165
+ if (deployClone && stamp.checkout && norm(stamp.checkout) !== norm(deployClone)) {
166
+ return { state: "fail", message:
167
+ `the updater matches its master${ageNote}, but it deploys a DIFFERENT tree than the one being `
168
+ + `checked: it places commits into ${stamp.checkout} while this check reads ${deployClone}. `
169
+ + "Whatever it keeps current, it is not this one." };
170
+ }
171
+
172
+ const into = stamp.checkout ? `, into ${stamp.checkout}` : "";
173
+ return { state: "pass", message:
174
+ `the running copy of the updater matches its master${ageNote}${into}, both digesting ${short(wsha)}`
175
+ + `, master read at commit ${short(stamp.masterCommit)}. This says the running copy matches the `
176
+ + "master AS THIS BOX HOLDS IT: the store it is read from is not pulled automatically, so a store "
177
+ + "nobody has fetched leaves both sides equally old and equally in agreement." };
178
+ }
@@ -22,12 +22,12 @@ import { readFileSync, readdirSync } from "node:fs";
22
22
  import { join, dirname } from "node:path";
23
23
  import { isLiveQueueMarker } from "./queue-markers.mjs"; // the wall's live-state vocabulary, not a fourth copy
24
24
 
25
- // The daily allowance a brand owner gets when its profile names no runCaps. Declared here rather
26
- // than imported from runner.mjs: a request path must not pull the runner (and its queue/dispatch
27
- // machinery) in. A test pins the two constants to the same value so they cannot drift apart.
25
+ // The daily allowance a brand owner gets when its profile names no runCaps. THE ONE DECLARATION, here in
26
+ // the leaf because a request path must not pull the runner (and its queue/dispatch machinery) in;
27
+ // runner.mjs imports it from here. There used to be a second, held equal by a test, and the screen that
28
+ // advertises the allowance and the gate that enforces it now cannot disagree by construction.
28
29
  //
29
- // THIS ONE IS THE ADVERTISER AND runner.mjs IS THE ENFORCER, so they move together or the screen promises
30
- // what the gate refuses. raised both from the beta's 2 to 20.
30
+ // Raised from the beta's 2 to 20.
31
31
  export const DEFAULT_CLIENT_DAILY_RUNS = 20;
32
32
 
33
33
  /**
@@ -1,5 +1,11 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.0-beta.2
4
+
5
+ ### Patch Changes
6
+
7
+ - eacfce4: Fixed: An assistant reading a knockout's filings through the connector now gets each filing's page at the trade mark office, as the report does. Where that register publishes no page for a single record, it gets the office and the number instead. Before, it got a reference that opens nowhere.
8
+
3
9
  ## 0.3.0-beta.1
4
10
 
5
11
  ### Patch Changes
@@ -259,22 +259,25 @@ if (isMain) {
259
259
  // are useful without it, and failing closed here would turn a config slip into an outage.
260
260
  try {
261
261
  const { loadProfiles } = await import("../driver/profiles.mjs");
262
- const roster = [...loadProfiles({ force: true }).keys()].sort();
262
+ const profiles = loadProfiles({ force: true });
263
+ const roster = [...profiles.keys()].sort();
263
264
  const where = envFrom(process.env, "CLEAROTRON_CUSTOMERS_DIR") || "(unset — BUNDLED demo roster at driver/profiles)";
264
265
  log(`roster: ${roster.length} customer(s) from ${where} — [${roster.join(", ")}]`);
265
- if (!envFrom(process.env, "CLEAROTRON_CUSTOMERS_DIR")) {
266
- // — SAME FACT, DIFFERENT READER. "Every real customer will be refused" is
267
- // true and useless to a demo visitor, who has none and wants none; what a first-time reader needs
268
- // is what the demo IS. Composed once, in driver/demo-posture.mjs, because the other mis-aimed
269
- // warning of this pair lives in the portal process and the two must not answer differently.
270
- //
271
- // OUTSIDE A DEMO IT IS UNCHANGED AND STILL A WARNING — an operator whose roster is the bundled one
272
- // has a real deployment that will refuse every real customer.
273
- const posture = demoPostureLine(process.env);
274
- if (posture) log(posture);
275
- else log(`WARNING: CLEAROTRON_CUSTOMERS_DIR is unset, so start_run is validating against the BUNDLED demo roster.`
266
+ // — SAME FACT, DIFFERENT READER. "Every real customer will be refused" is
267
+ // true and useless to a demo visitor, who has none and wants none; what a first-time reader needs
268
+ // is what the demo IS. Composed once, in driver/demo-posture.mjs, because the other mis-aimed
269
+ // warning of this pair lives in the portal process and the two must not answer differently.
270
+ //
271
+ // ASKED BEFORE THE STORE IS. A demo runs on a store of its own under its base, so an unset store no
272
+ // longer tells a demo from an operator's install. The line is composed from the roster read above.
273
+ //
274
+ // OUTSIDE A DEMO IT IS UNCHANGED AND STILL A WARNING — an operator whose roster is the bundled one
275
+ // has a real deployment that will refuse every real customer.
276
+ const posture = demoPostureLine(process.env, { roster: profiles });
277
+ if (posture) log(posture);
278
+ else if (!envFrom(process.env, "CLEAROTRON_CUSTOMERS_DIR"))
279
+ log(`WARNING: CLEAROTRON_CUSTOMERS_DIR is unset, so start_run is validating against the BUNDLED demo roster.`
276
280
  + " Every real customer will be refused with \"names no known customer\". Set it to the config store's profiles directory.");
277
- }
278
281
  const granted = grantedAccounts(envFrom(process.env, "CLEAROTRON_ACCESS_FILE"));
279
282
  const missing = granted.filter((a) => !roster.includes(a));
280
283
  if (missing.length) {
@@ -20,6 +20,13 @@ export {
20
20
  // exists: a second copy of a path table drifts, and the drift shows up as an artifact reported missing.
21
21
  export { koPaths } from "../../driver/stages-knockout.mjs";
22
22
 
23
+ // HOW PUBLISH ADDRESSES A KNOCKOUT'S LISTED FILINGS. On a register with no record pages of its own, each
24
+ // filing with an office number is given the office's own page, or the office and the number and why that is
25
+ // not a link, and the report, the workbook and report-data.json state it. The evidence view states it from
26
+ // this same function rather than from a second reading of the numbers. office-record-links.mjs imports
27
+ // nothing, so the read-only server stays light.
28
+ export { addressListedFilings } from "../../driver/publish/office-record-links.mjs";
29
+
23
30
  // stripInternal/stripEngineInternals/stripTelemetry are the driver's OWN client-safety transforms — the
24
31
  // same ones publish/render.mjs applies to the client HTML export. lib/scrub.mjs composes them so the MCP
25
32
  // client surface and the delivered report answer "what may a client see?" from ONE definition ( R1:
@@ -27,7 +27,7 @@
27
27
  import { existsSync, readFileSync, readdirSync } from "node:fs";
28
28
  import { basename, join } from "node:path";
29
29
 
30
- import { koPaths } from "./driver.mjs";
30
+ import { koPaths, addressListedFilings } from "./driver.mjs";
31
31
  import { driverDir } from "../../shared/driver-dir.mjs";
32
32
 
33
33
  const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
@@ -207,14 +207,26 @@ const knockoutNegatives = (doc) => marksOf(doc).flatMap((m) =>
207
207
  export function knockoutEvidence(run) {
208
208
  const doc = knockoutDoc(run);
209
209
  const recordsDoc = readJson(koPaths(run.runDir).registerRecords);
210
+ // EACH FILING ADDRESSED AS PUBLISH ADDRESSES IT, keyed on the sidecar's own register. On a register with
211
+ // no record pages of its own, the listing's link is the engine's handle, `/mark/<office>/<id>`, which a
212
+ // reader can open nowhere, and the report never shows it for a filing with an office number: it gives the
213
+ // office's own page, or the office and the number and why that is not a link. This view states the same,
214
+ // from the same function over the same sidecar, so the connector and the report cannot give one filing
215
+ // two links. On any other register the call sets nothing, and every row is exactly as it was.
216
+ if (Array.isArray(recordsDoc?.marks)) addressListedFilings(recordsDoc);
210
217
  const out = [];
211
218
 
212
219
  for (const entry of (Array.isArray(recordsDoc?.marks) ? recordsDoc.marks : [])) {
213
220
  for (const r of (Array.isArray(entry.records) ? entry.records : [])) {
221
+ // report-data.json's own field for the same filing, in its shape.
222
+ const office = r.officeLink ? { label: r.officeLink.label, href: r.officeLink.href, reason: r.officeLink.reason } : null;
214
223
  out.push({
215
224
  layer: "register", mark: r.mark ?? entry.name ?? null, owner: r.owner ?? null,
216
225
  country: r.territory ?? null, classes: r.classes ?? [], status: r.status ?? null,
217
- url: r.url ?? null, recordId: r.recordId ?? null,
226
+ // The source link: the office's page where the filing has one, none where it is cited by number,
227
+ // and the listing's own link only for a filing publish did not address.
228
+ url: office ? office.href : (r.url ?? null), recordId: r.recordId ?? null,
229
+ ...(office ? { officeRecord: office } : {}),
218
230
  matchedForm: r.matchedForm ?? null, matchedBasis: r.matchedBasis ?? null,
219
231
  // `retrieved` is what the provider actually returned for this row, and it is a FACT about the
220
232
  // fetch rather than about the filing. A reader deciding how much weight to give a row needs it.
@@ -19,7 +19,8 @@ import { probeQueueWatch, unwatchedQueueWarning } from "../../driver/queue-watch
19
19
  import { resolveRun, runAccountKey } from "./runs.mjs";
20
20
  import { assertAccountAccess, attributionOf } from "../../shared/scope.mjs";
21
21
  import { requestCancel, isCancelled } from "../../driver/cancel.mjs"; // one marker shape, shared with the engine
22
- import { readEngineChild, engineChildIsLive } from "../../driver/engine/child-record.mjs"; //
22
+ import { readEngineChild, engineChildIsLive, endEngineChild } from "../../driver/engine/child-record.mjs"; //
23
+ import { killEscalateMs } from "../../driver/engine/common.mjs"; // the watchdogs' grace, reused by an immediate stop
23
24
  import { stopReason } from "../../shared/stop-reason.mjs"; //
24
25
  import { envFrom } from "../../shared/env-aliases.mjs"; // — resolves EITHER spelling; names the retired one because that is the live-writable half
25
26
 
@@ -290,9 +291,11 @@ export function startRun(args = {}, { scope } = {}) {
290
291
  }
291
292
 
292
293
  // stop_run — cancel. A not-yet-claimed queue job is removed (a REAL cancel before any spend). A running run
293
- // gets a .cancel sentinel + honest status (in-flight turns are not force-killed from here). id cancels by
294
- // queue id; runId cancels a started run.
295
- export function stopRun(args = {}, { scope } = {}) {
294
+ // gets a .cancel sentinel + honest status; its turn in flight is ended from here only when `immediate` asks
295
+ // for that. id cancels by queue id; runId cancels a started run.
296
+ //
297
+ // `endTurn` is what ends that turn. The dispatch passes `{ scope }` alone, so only a test reaches it.
298
+ export async function stopRun(args = {}, { scope, endTurn = endEngineChild } = {}) {
296
299
  const agent = args.agent ? String(args.agent) : "";
297
300
  if (args.id) {
298
301
  const qdir = queueDir(agent);
@@ -384,14 +387,27 @@ export function stopRun(args = {}, { scope } = {}) {
384
387
  // kills a stranger. This is acceptance 5's already-exited case.
385
388
  immediate = { attempted: false, why: "the recorded engine turn has already exited — the cancel stands", pid: child.pid };
386
389
  } else {
387
- try {
388
- process.kill(child.pid, "SIGTERM");
389
- immediate = { attempted: true, signalled: "SIGTERM", pid: child.pid };
390
- } catch (e) {
390
+ // SIGNALLED IS NOT ENDED, so the answer waits to see. `endTurn` sends SIGTERM to the turn — to its
391
+ // whole process group when the record names the group's leader, which both engines' detached
392
+ // spawns make it — watches until nothing of it is left, and sends SIGKILL to whatever remains
393
+ // when the grace runs out. `ended` is what it SAW, and the sentences below are written from that
394
+ // field alone: `process.kill` returning says only that the OS accepted the signal for delivery.
395
+ //
396
+ // Measured on the owner's WSL install of 0.3.0-beta.1: this branch answered "the step in flight
397
+ // has been ended ... terminal in seconds", read off the signal alone, and the run stopped at the
398
+ // next step boundary.
399
+ //
400
+ // The portal waits 30 seconds for this answer, and this waits for the turn before answering: the
401
+ // grace, then up to two seconds after a SIGKILL. The cap keeps a long grace set for the watchdogs
402
+ // from outlasting the portal's wait.
403
+ const took = await endTurn(child, { graceMs: Math.min(killEscalateMs(), 10000) });
404
+ immediate = took?.signalled
405
+ ? { attempted: true, signalled: took.signalled, escalated: took.escalated ?? null, group: took.group === true,
406
+ pid: child.pid, ended: took.ended === true,
407
+ ...(took.ended === true ? {} : { why: "the step in flight was told to stop and had not ended when it was last checked" }) }
391
408
  // ESRCH — it died between the liveness read and the signal, which is a race we lose harmlessly.
392
409
  // EPERM — it is not ours to signal, which is a real finding and must not read as success.
393
- immediate = { attempted: true, signalled: null, pid: child.pid, error: e?.code ?? String(e?.message ?? e) };
394
- }
410
+ : { attempted: true, signalled: null, pid: child.pid, error: took?.error ?? "no signal was sent" };
395
411
  }
396
412
  }
397
413
 
@@ -443,18 +459,24 @@ export function stopRun(args = {}, { scope } = {}) {
443
459
  // distinguish them is the state the owner already told us says nothing. A pressed-immediate that
444
460
  // could not find a turn to end is reported as the boundary stop it actually became; presenting it
445
461
  // as an immediate stop would be the second silent thing in a row on the same control.
462
+ //
463
+ // AND IT SAYS "ENDED" ONLY FROM `ended`, which is what the stop saw. A turn that was told to stop and
464
+ // was still there afterwards is reported as the boundary stop it became.
465
+ const fellBack = immediate?.why ?? immediate?.error ?? "the step in flight could not be ended";
446
466
  if (alreadyStopping) {
447
467
  return { ok: true, action: "already-stopping", runId: run.runId, requestedAt: rec?.ts ?? null, immediate,
448
- note: immediate?.attempted
449
- ? `Already stopping — the request was filed at ${rec?.ts ?? "an earlier press"}, and the step in flight has now been ended. The run goes terminal in seconds.`
450
- : `Already stopping — the request was filed at ${rec?.ts ?? "an earlier press"} and the step in flight is being allowed to finish. Pressing again changes nothing.` };
468
+ note: immediate?.ended === true
469
+ ? `Already stopping — the request was filed at ${rec?.ts ?? "an earlier press"}, and the step in flight has now ended. Nothing will be delivered.`
470
+ : immediate?.attempted
471
+ ? `Already stopping — the request was filed at ${rec?.ts ?? "an earlier press"}. An immediate stop was asked for and could not be made: ${fellBack}. The run ends at its next step boundary.`
472
+ : `Already stopping — the request was filed at ${rec?.ts ?? "an earlier press"} and the step in flight is being allowed to finish. Pressing again changes nothing.` };
451
473
  }
452
- if (immediate?.attempted && immediate.signalled)
474
+ if (immediate?.ended === true)
453
475
  return { ok: true, action: "cancel-requested", runId: run.runId, requestedAt: rec?.ts ?? null, immediate,
454
- note: "Stopping now. The step in flight has been ended rather than allowed to finish, so the run goes terminal in seconds. What that step had already spent is spent, and everything recorded before it is kept." };
476
+ note: "Stopping now. The step in flight has ended: what it had already spent is spent, everything recorded before it is kept, and nothing will be delivered." };
455
477
  if (args.immediate === true)
456
478
  return { ok: true, action: "cancel-requested", runId: run.runId, requestedAt: rec?.ts ?? null, immediate,
457
- note: `Stopping at the next step boundary. An immediate stop was asked for and could not be made: ${immediate?.why ?? immediate?.error ?? "the step in flight could not be ended"}. A step already under way finishes first. Nothing will be delivered.` };
479
+ note: `Stopping at the next step boundary. An immediate stop was asked for and could not be made: ${fellBack}. A step already under way finishes first. Nothing will be delivered.` };
458
480
  return { ok: true, action: "cancel-requested", runId: run.runId, requestedAt: rec?.ts ?? null,
459
481
  note: "Stopping. The run ends at its next step boundary; a step already under way finishes first, and what it has already spent is spent. Nothing will be delivered." };
460
482
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.3.0-beta.1",
3
+ "version": "0.3.0-beta.2",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
@@ -17,6 +17,6 @@
17
17
  "jose": "^6.2.3"
18
18
  },
19
19
  "engines": {
20
- "node": ">=22"
20
+ "node": ">=22.13.0"
21
21
  }
22
22
  }
@@ -594,7 +594,7 @@ const TOOL_DEFS = [
594
594
  { name: "plan_run", description: "FREE PREVIEW — resolve what a search WOULD do and return it, spending nothing and queueing nothing. Takes the SAME arguments as start_run. Use this BEFORE start_run whenever the requester has not already confirmed the specifics: it reports the PRODUCT that resolves and WHERE it came from (their request, the account default, a saved search, or the territories themselves), the territories and marketplaces that would ACTUALLY be searched, the turnaround, and any blockers as questions to put back. Show the result to the requester; then call start_run with the SAME arguments to actually run it. Nothing here reserves or holds anything.", inputSchema: { type: "object", properties: { markName: { type: "string", description: "The mark (single-mark form). REQUIRED unless marks[] is given." }, marks: { type: "array", items: { type: "object", properties: { name: { type: "string" }, classes: { type: "array", items: { type: "number" } }, ref: { type: "string" } }, required: ["name"] } }, classes: { type: "array", items: { type: "number" } }, goods: { type: "string" }, jurisdictions: { type: "array", items: { type: "string" } }, platforms: { type: "array", items: { type: "string" } }, customer: { type: "string" }, profileKey: { type: "string", description: "The customer ACCOUNT key (see list_profiles). REQUIRED for an accounts-scoped session (your grant names the keys — see describe_options)." }, projectKey: { type: "string" }, forwarder: { type: "string", description: "Requester/reply-routing key — same as start_run, so the preview describes the job that would actually be built. Account sessions may omit it — it is stamped from your verified identity." }, forwarderEmail: { type: "string" }, ref: { type: "string" }, product: { type: "string", enum: ORDERABLE_PRODUCTS, description: PRODUCT_DESC }, nativeLanguage: { type: "boolean", description: "OPTIONAL, and only on a multi-country-focus-search: the native-language investigation. Send TRUE or omit the field — FALSE IS REFUSED, not honoured: the toggle only ever added, so there is nothing for false to switch off, and accepting it would let you believe you had removed a reading that runs anyway. It runs AUTOMATICALLY on a full-country-search and is not part of the other two, so asking for it there is refused rather than accepted and ignored. It routes on territory — the scope must name one it covers." }, worldwide: { type: "boolean", description: "Search EVERYWHERE, and refuse to be narrowed by the account's own default territories. This is not the same as omitting jurisdictions, which means \"whatever the account says\" — the two used to be indistinguishable on this wire, which is how a request that bought everywhere ran an account's seven countries. Never send \"Worldwide\" as a jurisdictions entry." }, recipeKey: { type: "string" }, upfrontInstructions: { type: "string" }, deadline: { type: "string" }, deliveryRoute: { type: "string", enum: ["email", "portal"], description: "OPTIONAL: how the delivered packet leaves — email (default). \"portal\" is reserved for the portal delivery lane and CLARIFIES at admission until it ships (never a silent no-op) — so the preview BLOCKS it here rather than describing a job start_run could not run." }, commercialFlexibility: { type: "string" }, priorUse: { type: "string" }, campaignShape: { type: "string", description: "Campaign-shape FACTS from the request/client — how the mark will be deployed: standalone brand vs flavour/sub-brand under a NAMED house mark; seasonal/limited vs permanent; launch scale. Verbatim facts only — the engine records them on the matter frame instead of inferring a launch shape." }, customerUnknown: { type: "boolean" } }, required: ["forwarder"] } },
595
595
  // ---- OPS write-face (requires an ops token; never exposed to a user/internal session) ----
596
596
  { name: "start_run", description: "OPS-ONLY. Enqueue a NEW clearance/search run the driver will pick up. Provide markName (or marks[] for a batch) + forwarder (and ideally classes/goods/customer/profileKey/ref). Returns the queue id + slug; the runId/codename is assigned when the runner claims it — poll list_runs (by markName) or run_changes for progress. Spends real money once it runs.", inputSchema: { type: "object", properties: { agent: { type: "string", description: "Which agent workspace's queue. Omit for the deployment default (CLEAROTRON_QUEUE_DIR or the default agent's queue — docs/INTAKE.md)." }, markName: { type: "string", description: "The mark (single-mark form). Required unless marks[] is provided." }, marks: { type: "array", items: { type: "object", properties: { name: { type: "string" }, classes: { type: "array", items: { type: "number" } }, ref: { type: "string" } }, required: ["name"] }, description: "Batch form: EVERY name of the batch in ONE job. Only a knockout-search reads more than one name at a time; a clearance reads one, and more is refused rather than truncated." }, classes: { type: "array", items: { type: "number" }, description: "Nice classes — whole numbers 1–45 (1–34 goods, 35–45 services). classes OR goods; either suffices." }, goods: { type: "string" }, jurisdictions: { type: "array", items: { type: "string" }, description: "WHERE the search points — and, for a clearance, WHICH search it is: one country is a full-country-search, a region or two-or-more countries a multi-country-focus-search. AUTHORITATIVE (the matter frame is told not to widen past them). Omit ⇒ the project/customer default territories; send worldwide:true to search everywhere instead. Names or codes both read; max 20." }, platforms: { type: "array", items: { type: "string" }, description: "OPTIONAL per-run SCOPE: extra marketplaces to sweep, as bare store domains e.g. [\"gnc.com\"]. ADDED to the account's own marketplaces — additive only, never a replacement (a client's platform list is a mandate). Max 10; \"web\" is implicit and must not be listed." }, customer: { type: "string" }, profileKey: { type: "string", description: "The customer ACCOUNT key (see list_profiles) whose profile rates this run; omit for the neutral generic profile. REQUIRED for an accounts-scoped session (your grant names the keys — see describe_options)." }, forwarder: { type: "string", description: "Requester/reply-routing key — rides the delivery packet so the integrator knows who gets the report (docs/DELIVERY.md). Account sessions may omit it — it is stamped from your verified identity." }, forwarderEmail: { type: "string" }, ref: { type: "string" }, provider: { type: "string" }, brief: { type: "string" }, upfrontInstructions: { type: "string" }, projectKey: { type: "string", description: "OPTIONAL (spec 62): the PROJECT/engagement key UNDER profileKey (see that customer's projects[] in list_profiles) whose overlay rates this run; omit to run on the customer profile. An unknown key clarifies at intake." }, product: { type: "string", enum: ORDERABLE_PRODUCTS, description: PRODUCT_DESC }, nativeLanguage: { type: "boolean", description: "OPTIONAL, and only on a multi-country-focus-search: the native-language investigation. Send TRUE or omit the field — FALSE IS REFUSED, not honoured: the toggle only ever added, so there is nothing for false to switch off, and accepting it would let you believe you had removed a reading that runs anyway. It runs AUTOMATICALLY on a full-country-search and is not part of the other two, so asking for it there is refused rather than accepted and ignored. It routes on territory — the scope must name one it covers." }, worldwide: { type: "boolean", description: "Search EVERYWHERE, and refuse to be narrowed by the account's own default territories. This is not the same as omitting jurisdictions, which means \"whatever the account says\" — the two used to be indistinguishable on this wire, which is how a request that bought everywhere ran an account's seven countries. Never send \"Worldwide\" as a jurisdictions entry." }, recipeKey: { type: "string", description: "OPTIONAL alternative selector: a saved search (recipe) slug for this customer. Mutually exclusive with product — a saved search already carries one." }, deliveryRoute: { type: "string", enum: ["email", "portal"], description: "OPTIONAL: how the delivered packet leaves — email (default). \"portal\" is reserved for the portal delivery lane and CLARIFIES at admission until it ships (never a silent no-op)." }, parentRunId: { type: "string", description: "OPTIONAL escalation lineage: the runId this run escalates from (e.g. a knockout HIGH mark → this clearotron)." }, deliverableSpec: { type: "string" }, commercialFlexibility: { type: "string" }, priorUse: { type: "string" }, campaignShape: { type: "string", description: "Campaign-shape FACTS from the request/client — how the mark will be deployed: standalone brand vs flavour/sub-brand under a NAMED house mark; seasonal/limited vs permanent; launch scale. Verbatim facts only — the engine records them on the matter frame instead of inferring a launch shape." }, deadline: { type: "string", description: "ISO-8601 — drives the deadline envelope." }, customerUnknown: { type: "boolean" }, dupOverride: { type: "boolean", description: "Requester-confirmed force-run past matter dedup." }, clientPrincipal: { type: "boolean", description: "Set by the client portal only: this run was started by a CLIENT and consumes that account's runCaps.dailyRuns allowance. Omit for staff, agent and email-door runs — absence means uncapped, and setting it can only restrict the run that carries it." }, rawRequest: { type: "string" }, forwarderDomain: { type: "string" }, msgId: { type: "string" }, conversationId: { type: "string" }, id: { type: "string" } }, required: ["forwarder"] } },
597
- { name: "stop_run", description: "OPS-ONLY. Cancel a run. Pass id to remove a not-yet-claimed queued job (a real cancel, no spend). Pass runId to file a cancel request on a started run. By default the cancel is COOPERATIVE — the sentinel is written and a turn already in flight is allowed to finish, which has no deadline: a reasoning turn can run for tens of minutes. Add immediate:true to also end that turn, so the run goes terminal in seconds.", inputSchema: { type: "object", properties: { agent: { type: "string" }, id: { type: "string", description: "A queued job id (from start_run) — dequeues before the runner claims it." }, runId: { type: "string", description: "A started run's runId — files a cancel sentinel." }, immediate: { type: "boolean", description: "Stop NOW instead of at the next step boundary. The cancel sentinel is written FIRST and then the run's own recorded engine turn is ended, in that order — the sentinel is what makes the record clean, so the terminal names the stage, the actor and the request time instead of leaving a run that reads as still running. The step in flight is lost; everything already recorded is kept. Falls back to the boundary stop, and SAYS SO in the answer, when there is no live turn to end — a run between steps, or one whose turn has already exited. Never send this expecting a guaranteed instant stop: the answer states which of the two actually happened."}, onBehalfOf: { type: "string", description: "The identifier of a human this caller has itself verified (#1378). RECORDED BESIDE the token's own principal, never instead of it — a stop by the portal for alice@x is written `portal:alice@x`, so an asserted name is never mistaken for a proved one. Bare identifiers only; anything else is dropped." } } } },
597
+ { name: "stop_run", description: "OPS-ONLY. Cancel a run. Pass id to remove a not-yet-claimed queued job (a real cancel, no spend). Pass runId to file a cancel request on a started run. By default the cancel is COOPERATIVE — the sentinel is written and a turn already in flight is allowed to finish, which has no deadline: a reasoning turn can run for tens of minutes. Add immediate:true to also end that turn: it is sent a stop, anything of it still running a few seconds later is killed, and the answer says the turn ended only once it has seen it end. If it could not be ended, the run ends at its next step boundary, and the answer says that instead.", inputSchema: { type: "object", properties: { agent: { type: "string" }, id: { type: "string", description: "A queued job id (from start_run) — dequeues before the runner claims it." }, runId: { type: "string", description: "A started run's runId — files a cancel sentinel." }, immediate: { type: "boolean", description: "Stop NOW instead of at the next step boundary. The cancel sentinel is written FIRST and then the run's own recorded engine turn is stopped, in that order — the sentinel is what makes the record clean, so the terminal names the stage, the actor and the request time instead of leaving a run that reads as still running. The turn is sent SIGTERM (to its whole process group when it leads one, as both engines spawn it, so the tools it started are reached too), watched, and sent SIGKILL if anything of it is still running when the grace runs out; the answer waits for that, which takes seconds. A turn that is stopped loses its step's work; everything already recorded is kept. Sending the stop is not the same as the turn ending, and the answer claims the second only when it has seen it (immediate.ended). Falls back to the boundary stop, and SAYS SO in the answer, when there is no live turn to signal — a run between steps, or one whose turn has already exited — or when the turn was still there after the SIGKILL. The answer states which of the two actually happened."}, onBehalfOf: { type: "string", description: "The identifier of a human this caller has itself verified (#1378). RECORDED BESIDE the token's own principal, never instead of it — a stop by the portal for alice@x is written `portal:alice@x`, so an asserted name is never mistaken for a proved one. Bare identifiers only; anything else is dropped." } } } },
598
598
  { name: "server_info", description: "The AGPL §13 source offer: this server's name, version, licence, source repository and the COMMIT IT IS RUNNING. Callable by every session kind — the offer is owed to whoever interacts with the service. Reads no run and no account data.", inputSchema: { type: "object", properties: {} } },
599
599
  { name: "list_outbox_events", description: "Integrator discovery read (docs/DELIVERY.md): every pending outbox event — delivered markers plus the self-contained run-failed / intake-rejected / duplicate-skipped / late-bind-ack packets. Route each, then mark_sent (delivered) or ack_event (the rest).", inputSchema: { type: "object", properties: {} } },
600
600
  { name: "get_delivery_packet", description: "The run's send payload: _driver/delivery.json (subject, recipient routing, verbatim emailBodyHtml, whatsapp line) and/or failure.json, plus sendPending/.sent state. NOT exposed to client tokens.", inputSchema: { type: "object", properties: { runId: { type: "string" } }, required: ["runId"] } },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.3.0-beta.1",
4
+ "version": "0.3.0-beta.2",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -10943,6 +10943,7 @@ var NAV = [
10943
10943
  path: "/portal/people",
10944
10944
  icon: "users",
10945
10945
  needs: "manage",
10946
+ hidden: true,
10946
10947
  scope: "account"
10947
10948
  },
10948
10949
  {
@@ -11065,6 +11066,7 @@ function avatarMenuFor(who, entries = NAV) {
11065
11066
  const pick = (id) => all.find((e) => e.id === id);
11066
11067
  return [
11067
11068
  pick("preferences"),
11069
+ pick("people"),
11068
11070
  pick("admin.config"),
11069
11071
  pick("about")
11070
11072
  ].filter((e) => !!e);
@@ -11405,6 +11407,9 @@ function companyFactsMap(granted, roster) {
11405
11407
  //#endregion
11406
11408
  //#region src/shell/companyRows.ts
11407
11409
  var GENERIC_KEY = GENERIC_ACCOUNT;
11410
+ function switcherKeys(me, roster) {
11411
+ return [...(me.allAccounts ? (roster ?? []).map((c) => c.key) : me.accounts).filter((k) => !isGenericKey(k)), ...me.genericOrgs.map(genericFor)];
11412
+ }
11408
11413
  var GENERIC_LINE = "Run any clearance against the default settings, with no company set up.";
11409
11414
  function pickerGroups(keys, orgOf, organisations, companyName, factsFor) {
11410
11415
  const rowFor = (key) => isGenericKey(key) ? {
@@ -11445,6 +11450,9 @@ function pickerGroups(keys, orgOf, organisations, companyName, factsFor) {
11445
11450
  function pickerRows(keys, orgOf, organisations, companyName, factsFor) {
11446
11451
  return pickerGroups(keys, orgOf, organisations, companyName, factsFor).groups.flatMap((g) => g.rows);
11447
11452
  }
11453
+ function switcherLabel(row) {
11454
+ return row.generic ? `${row.name} (Default)` : row.name;
11455
+ }
11448
11456
  //#endregion
11449
11457
  //#region src/shell/accessWords.ts
11450
11458
  function permissionsPhrase(p) {
@@ -11646,7 +11654,7 @@ function AppShell({ render }) {
11646
11654
  };
11647
11655
  const facts = companyFactsMap(me.accountFacts, rosterResult?.kind === "ok" ? rosterResult.value : []);
11648
11656
  const factsFor = (key) => facts[isGenericKey(key) ? GENERIC_ACCOUNT : key];
11649
- const ownerKeys = [...(me.allAccounts ? rosterResult?.kind === "ok" ? rosterResult.value.map((c) => c.key) : [] : me.accounts).filter((k) => !isGenericKey(k)), ...me.genericOrgs.map(genericFor)];
11657
+ const ownerKeys = switcherKeys(me, rosterResult?.kind === "ok" ? rosterResult.value : null);
11650
11658
  const rosterOrgs = Object.fromEntries((rosterResult?.kind === "ok" ? rosterResult.value : []).flatMap((c) => c.org ? [[c.key, c.org]] : []));
11651
11659
  const orgOf = (key) => orgOfGeneric(key) ?? me.accountOrgs[key] ?? rosterOrgs[key] ?? null;
11652
11660
  const organisations = me.organisations;
@@ -11903,7 +11911,7 @@ function AppShell({ render }) {
11903
11911
  function BrandOwnerSwitcher({ grouped, value, onChange }) {
11904
11912
  const option = (r) => (0, import_jsx_runtime.jsx)("option", {
11905
11913
  value: r.key,
11906
- children: r.name
11914
+ children: switcherLabel(r)
11907
11915
  }, r.key);
11908
11916
  return (0, import_jsx_runtime.jsxs)("select", {
11909
11917
  value: value ?? "",
@@ -12523,14 +12531,6 @@ function Card({ run, ctx, onChanged }) {
12523
12531
  onBoundary: () => void stop(false),
12524
12532
  onCancel: () => setAsking(false)
12525
12533
  }) : null,
12526
- stopping ? (0, import_jsx_runtime.jsx)("div", {
12527
- className: "home2-stopping",
12528
- children: took?.note ? took.note : (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
12529
- "Stopping — ",
12530
- run.step ? `letting “${run.step}” finish` : "letting the step in flight finish",
12531
- ". A reasoning step can take tens of minutes and has no deadline. Nothing further will start, and nothing will be delivered."
12532
- ] })
12533
- }) : null,
12534
12534
  canAck ? (0, import_jsx_runtime.jsx)("button", {
12535
12535
  type: "button",
12536
12536
  className: "home2-ack",
@@ -12539,7 +12539,15 @@ function Card({ run, ctx, onChanged }) {
12539
12539
  children: "Acknowledge"
12540
12540
  }) : null
12541
12541
  ]
12542
- })
12542
+ }),
12543
+ stopping ? (0, import_jsx_runtime.jsx)("div", {
12544
+ className: "home2-stop-note",
12545
+ children: took?.note ? took.note : (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
12546
+ "Stopping — ",
12547
+ run.step ? `letting “${run.step}” finish` : "letting the step in flight finish",
12548
+ ". A reasoning step can take tens of minutes and has no deadline. Nothing further will start, and nothing will be delivered."
12549
+ ] })
12550
+ }) : null
12543
12551
  ]
12544
12552
  })]
12545
12553
  });
@@ -12633,13 +12641,13 @@ function StopChoice({ name, step, stoppable, onImmediate, onBoundary, onCancel }
12633
12641
  className: "stop-choice-opt stop-choice-now",
12634
12642
  onClick: onImmediate,
12635
12643
  children: [(0, import_jsx_runtime.jsx)("b", { children: "Stop now" }), (0, import_jsx_runtime.jsxs)("span", { children: [
12636
- "Ends ",
12644
+ "Sends a stop to ",
12637
12645
  step ? (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
12638
12646
  "“",
12639
12647
  step,
12640
12648
  "”"
12641
12649
  ] }) : (0, import_jsx_runtime.jsx)(import_jsx_runtime.Fragment, { children: "the step in flight" }),
12642
- " rather than waiting for it. The run is over in seconds. That step’s work is lost; everything recorded before it is kept."
12650
+ " rather than waiting for it. If it takes the stop the run is over in seconds; if it will not, the run stops at the next step instead. That step’s work is lost; everything recorded before it is kept."
12643
12651
  ] })]
12644
12652
  })]
12645
12653
  }),
@@ -13084,7 +13092,7 @@ function Clearances({ ctx }) {
13084
13092
  })
13085
13093
  });
13086
13094
  if (!result) return (0, import_jsx_runtime.jsx)(Loading, {});
13087
- if (!runs.length) return (0, import_jsx_runtime.jsx)(FirstRun, { onNew: canRun(ctx.me) ? () => ctx.go("/portal/new") : null });
13095
+ if (!allRuns.length) return (0, import_jsx_runtime.jsx)(FirstRun, { onNew: canRun(ctx.me) ? () => ctx.go("/portal/new") : null });
13088
13096
  const allMarks = rows.flatMap((r) => r.kind === "family" ? r.marks : [r]);
13089
13097
  const pickedRunIds = () => allMarks.filter((m) => picked.has(m.id)).flatMap((m) => m.reads.map((r) => r.runId));
13090
13098
  const ungroupFamily = async (family) => {
@@ -13372,7 +13380,14 @@ function Clearances({ ctx }) {
13372
13380
  (0, import_jsx_runtime.jsx)("th", { children: sortBtn("risk", "Risk") }),
13373
13381
  (0, import_jsx_runtime.jsx)("th", { children: sortBtn("date", "Updated") })
13374
13382
  ] }) }),
13375
- (0, import_jsx_runtime.jsx)("tbody", { children: visible.map((r, i) => {
13383
+ (0, import_jsx_runtime.jsxs)("tbody", { children: [!visible.length ? (0, import_jsx_runtime.jsx)("tr", { children: (0, import_jsx_runtime.jsx)("td", {
13384
+ colSpan: (canGroup ? 6 : 5) + (showOwnerColumn ? 1 : 0),
13385
+ style: {
13386
+ padding: "22px 12px",
13387
+ color: "var(--text-muted)"
13388
+ },
13389
+ children: "No clearances match this view. Pick another company above, or widen the status filter."
13390
+ }) }) : null, visible.map((r, i) => {
13376
13391
  const before = visible[i - 1];
13377
13392
  const newGroup = grouped && (!before || runKey(r) !== runKey(before));
13378
13393
  return (0, import_jsx_runtime.jsxs)(import_react.Fragment, { children: [newGroup ? (0, import_jsx_runtime.jsx)("tr", {
@@ -13414,7 +13429,7 @@ function Clearances({ ctx }) {
13414
13429
  onRetire: canGroup ? retireMark : void 0,
13415
13430
  onRetireRun: canGroup ? retireRun : void 0
13416
13431
  })] }, r.id);
13417
- }) })
13432
+ })] })
13418
13433
  ]
13419
13434
  })
13420
13435
  }),
@@ -19039,7 +19054,7 @@ function NewCompany({ ctx }) {
19039
19054
  if (!raw.trim()) return [];
19040
19055
  return parseLines(raw, spec.commaSeparated ?? false).filter((e) => !spec.item.ok(e));
19041
19056
  });
19042
- const unmet = !typedName ? "Needs a name." : !key ? "Needs a key — type one below." : !orgChosen ? "Choose the organisation it belongs to." : refusedEntries.length ? `${refusedEntries.join(", ")} cannot be searched — fix or remove ${refusedEntries.length === 1 ? "it" : "them"}.` : null;
19057
+ const unmet = !typedName ? "Needs a name." : !key ? "Needs a key — type one below." : !orgChosen ? orgsHeld.length === 0 ? "No organisation is filed on this install yet. File one with: clearotron start --organisation \"<name>\"" : "Choose the organisation it belongs to." : refusedEntries.length ? `${refusedEntries.join(", ")} cannot be searched — fix or remove ${refusedEntries.length === 1 ? "it" : "them"}.` : null;
19043
19058
  async function create() {
19044
19059
  if (unmet || busy) return;
19045
19060
  setBusy(true);