@staix/agent-hub 0.19.0 → 0.21.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/CHANGELOG.md +70 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -5
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -3
- package/docs/agent-notes/daemon.md +20 -7
- package/docs/agent-notes/local-worker.md +5 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +20 -10
- package/docs/agent-notes/tests.md +13 -5
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +41 -5
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +598 -63
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +66 -10
- package/docs/security.md +397 -35
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-27-0.20.0.md +75 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +26 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1116 -284
- package/src/adapters/acp.ts +87 -3
- package/src/adapters/channel-peer.ts +11 -5
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +61 -7
- package/src/adapters/codex-appserver.ts +88 -5
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +10 -6
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +42 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/pi.ts +7 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/adapters/reviewer-print.ts +1 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +28 -16
- package/src/cli/identity.ts +23 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/main.ts +314 -71
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +33 -0
- package/src/cli/smoke-pty.ts +145 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +98 -16
- package/src/cli/upgrade.ts +66 -7
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +399 -56
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +162 -0
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +32 -5
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +8 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1228 -242
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +123 -17
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +85 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +76 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +46 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +7 -1
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/task-shell-default.ts +3 -2
- package/src/hub/tasks.ts +1047 -115
- package/src/hub/workflow.ts +1 -1
- package/src/local/deny.ts +21 -0
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/sandbox.ts +3 -1
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
package/src/cli/sdlc-output.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { duration, plural, relative, table, terminalText, wrap, type Span, type Tone } from "./console-state.ts";
|
|
6
6
|
|
|
7
7
|
type Line = Span[];
|
|
8
|
-
const s = (value: unknown, tone?: Tone): Span => ({ text: terminalText(value).replace(/[\n\t]/g, " "), ...(tone ? { tone } : {}) });
|
|
8
|
+
export const s = (value: unknown, tone?: Tone): Span => ({ text: terminalText(value).replace(/[\n\t]/g, " "), ...(tone ? { tone } : {}) });
|
|
9
9
|
const room = (columns?: number) => columns ?? Infinity;
|
|
10
10
|
const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
|
|
11
11
|
const yes = (value: unknown) => value === true ? "yes" : value === false ? "no" : "unknown";
|
|
@@ -29,7 +29,7 @@ function para(text: string, columns: number | undefined, tone?: Tone, indent = "
|
|
|
29
29
|
* the value does not fit in the room left of the label column, the value moves under the label instead, so a path or id
|
|
30
30
|
* is broken only when it is wider than the whole terminal.
|
|
31
31
|
*/
|
|
32
|
-
function pair(label: string, value: unknown, columns: number | undefined, labelWidth: number, tone?: Tone, indent = ""): Line[] {
|
|
32
|
+
export function pair(label: string, value: unknown, columns: number | undefined, labelWidth: number, tone?: Tone, indent = ""): Line[] {
|
|
33
33
|
const text = terminalText(value).replace(/[\n\t]/g, " ");
|
|
34
34
|
const head = indent + label.padEnd(labelWidth) + " ";
|
|
35
35
|
const longest = Math.max(0, ...text.split(/\s+/).map(textWidth));
|
|
@@ -52,7 +52,7 @@ const textWidth = (text: string) => Bun.stringWidth(text);
|
|
|
52
52
|
* widest cells wrap inside their column (a PR linked to thirty tasks wraps its TASKS cell, it does not push the
|
|
53
53
|
* table to 134 columns); and when even each column's longest unbreakable word cannot fit, one labelled block per row.
|
|
54
54
|
*/
|
|
55
|
-
function grid(head: string[], data: Span[][], columns: number | undefined): Line[] {
|
|
55
|
+
export function grid(head: string[], data: Span[][], columns: number | undefined): Line[] {
|
|
56
56
|
const natural = head.map((name, i) => Math.max(textWidth(name), ...data.map(row => textWidth(row[i]?.text ?? ""))));
|
|
57
57
|
const gaps = GAP * (head.length - 1);
|
|
58
58
|
if (columns === undefined || natural.reduce((sum, n) => sum + n, 0) + gaps <= columns) return table(head, data, room(columns), natural);
|
|
@@ -79,8 +79,8 @@ function blocks(head: string[], data: Span[][], columns: number): Line[] {
|
|
|
79
79
|
...head.slice(1).flatMap((name, i) => pair(name.toLowerCase(), row[i + 1]?.text ?? "", columns, label, row[i + 1]?.tone, " "))]);
|
|
80
80
|
}
|
|
81
81
|
/** A heading wraps to the width like any paragraph; a long repository name never pushes it past the terminal. */
|
|
82
|
-
const heading = (text: string, columns?: number): Line[] => wrap(text, room(columns), 2).map(line => [s(line, "strong")]);
|
|
83
|
-
const blank: Line = [s("")];
|
|
82
|
+
export const heading = (text: string, columns?: number): Line[] => wrap(text, room(columns), 2).map(line => [s(line, "strong")]);
|
|
83
|
+
export const blank: Line = [s("")];
|
|
84
84
|
const readyTone = (ready: unknown): Tone => ready === true ? "success" : "attention";
|
|
85
85
|
|
|
86
86
|
// ---- sdlc status / explain ----------------------------------------------------------------------------------------
|
|
@@ -115,6 +115,12 @@ function taskBlock(row: any, columns: number | undefined, detail: boolean): Line
|
|
|
115
115
|
out.push(...pair("tests", `${t.applicability === "bug" ? "bug fix" : t.applicability === "not_bug" ? "not a bug fix" : "bug status unknown"}, red-first ${String(t.redFirst).replace("_", " ")}, files ${t.fileState}`, columns, 11, undefined, " "));
|
|
116
116
|
out.push(...bullets("modified tests", list(t.modifiedTests), columns, undefined, " "), ...bullets("test blockers", list(t.blockers), columns, "attention", " "));
|
|
117
117
|
}
|
|
118
|
+
const e = row.evalGate;
|
|
119
|
+
if (e) {
|
|
120
|
+
const shown = e.state === "not_configured" ? "eval check not configured (suite choice pending the person)" : e.state === "coverage_unknown" ? "PR file coverage unknown" : `${e.check} ${String(e.state)}${e.passRate !== undefined ? `, pass rate ${e.passRate}` : ""}${e.suite !== undefined ? `, suite ${e.suite}` : ""}`;
|
|
121
|
+
out.push(...pair("eval gate", `configuration PR, ${shown}`, columns, 11, e.state === "passed" && !list(e.gaps).length ? undefined : "attention", " "));
|
|
122
|
+
out.push(...bullets("eval gaps", [...list(e.gaps), ...list(e.notes)], columns, "attention", " "));
|
|
123
|
+
}
|
|
118
124
|
const c = row.criteria;
|
|
119
125
|
if (c) {
|
|
120
126
|
out.push(...pair("criteria", `${c.ready ? "all met" : "not all met"}${c.id ? `, checklist ${c.id}` : ""}${c.note ? `, ${c.note}` : ""}`, columns, 11, readyTone(c.ready), " "));
|
|
@@ -175,6 +181,12 @@ export function renderSdlcRelease(reply: unknown, columns?: number, now = Date.n
|
|
|
175
181
|
if (a) out.push(blank, ...pair("authorization", `${a.state}: version ${a.receipt?.version ?? "unknown"} at ${short(a.receipt?.commit)}, approved ${moment(a.receipt?.at, now)}`, columns, w, a.state === "matched" ? "success" : "attention"));
|
|
176
182
|
else out.push(blank, ...pair("authorization", "none recorded: run ahub sdlc authorize-release <version>", columns, w, "muted"));
|
|
177
183
|
for (const t of list(r.trains)) out.push(...pair(`train #${t.release?.id ?? "?"}`, `${t.readiness?.trigger ?? "unknown"}${list(t.readiness?.blockers).length ? `, ${list(t.readiness.blockers).join("; ")}` : ""}`, columns, w, list(t.readiness?.blockers).length ? "attention" : undefined));
|
|
184
|
+
const rm = r.releaseMetrics;
|
|
185
|
+
if (rm) {
|
|
186
|
+
const mean = (values: unknown[]) => { const known = values.filter((v): v is number => typeof v === "number" && Number.isFinite(v)); return known.length ? span(known.reduce((a, b) => a + b, 0) / known.length) : "unknown"; };
|
|
187
|
+
out.push(blank, ...pair("releases", `${list(rm.intervals).length ? `interval ${mean(list(rm.intervals).map((i: any) => i.ms))} mean over ${plural(list(rm.intervals).length, "gap")}` : "interval unknown (needs two shipped releases with history)"}; lead time ${mean(list(rm.approvalToReleaseMs).map((x: any) => x.ms))} mean approval to release; hotfix rate ${percent(rm.hotfixRate)}${rm.hotfixCount === null || rm.hotfixCount === undefined ? "" : ` (${rm.hotfixCount})`}`, columns, w));
|
|
188
|
+
if (list(rm.unknown).length) out.push(...pair("unknown", `${plural(list(rm.unknown).length, "release")} without complete history: ${list(rm.unknown).map((id: number) => `#${id}`).join(", ")}`, columns, w, "attention", " "));
|
|
189
|
+
}
|
|
178
190
|
out.push(...bullets("blockers", list(r.blockers), columns, "attention"));
|
|
179
191
|
return out;
|
|
180
192
|
}
|
|
@@ -184,6 +196,7 @@ export function renderSdlcRelease(reply: unknown, columns?: number, now = Date.n
|
|
|
184
196
|
const SUMMARY_ROWS: [string, string, boolean][] = [
|
|
185
197
|
["acceptance_to_plan_ms", "acceptance to plan", true], ["acceptance_to_first_review_ms", "acceptance to first review", true], ["acceptance_to_merge_ms", "acceptance to merge", true],
|
|
186
198
|
["rework_rounds", "rework rounds", false], ["same_head_ci_reruns", "same-head CI reruns", false], ["stale_head_re_reviews", "stale-head re-reviews", false],
|
|
199
|
+
["review_rounds_at_merge", "review rounds at merge", false], ["ci_runs_at_merge", "CI runs at merge", false], ["github_reviews_at_merge", "GitHub reviews at merge", false],
|
|
187
200
|
];
|
|
188
201
|
const BAND_TONE: Record<string, Tone> = { ok: "success", log: "muted", diagnose: "attention", propose: "failure" };
|
|
189
202
|
/** `ahub sdlc metrics [--since ...]`: times and counts from board history, the two approval rates, stalls and band tiers. */
|
|
@@ -206,7 +219,7 @@ export function renderSdlcMetrics(result: unknown, columns?: number, now = Date.
|
|
|
206
219
|
if (st) out.push(blank, ...pair("stalls", `${st.open ?? 0} open${Object.keys(st.byKind ?? {}).length ? ` (${Object.entries(st.byKind).map(([k, n]) => `${k} ${n}`).join(", ")})` : ""}, ${st.doneWithoutWrites ?? 0} done without writes`, columns, 10, st.open ? "attention" : undefined));
|
|
207
220
|
if (m?.bands === "off" || m?.bands === undefined) out.push(...pair("bands", "off", columns, 10, "muted"));
|
|
208
221
|
else {
|
|
209
|
-
out.push(blank, ...grid(["BAND", "TIER", "BASELINE", "LAST", "Z", "
|
|
222
|
+
out.push(blank, ...grid(["BAND", "TIER", "BASELINE", "LAST", "Z", "SUBJECT"], list(m.bands).map((b: any) => [s(String(b.metric).replaceAll("_", " ")), s(b.tier, BAND_TONE[b.tier] ?? undefined), s(count(b.baseline)), s(count(b.last)), s(count(b.z)), s(b.lastTask ? `${b.subject === "release" ? "release " : b.subject === "scan" ? "scan " : ""}#${b.lastTask}` : "-", "taskRef")]), columns));
|
|
210
223
|
for (const b of list(m.bands)) if (b.reason && b.tier !== "ok") out.push(...para(`${String(b.metric).replaceAll("_", " ")}: ${b.reason}`, columns, "muted", " ", 2));
|
|
211
224
|
}
|
|
212
225
|
return out;
|
|
@@ -240,3 +253,28 @@ export function renderAuthorization(authorization: unknown, columns?: number, no
|
|
|
240
253
|
...pair("id", a?.id ?? "unknown", columns, w, "muted"), blank,
|
|
241
254
|
...para("A daemon restart revokes this authorization.", columns, "muted")];
|
|
242
255
|
}
|
|
256
|
+
|
|
257
|
+
// ---- sdlc merge ---------------------------------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
const MERGE_STATE: Record<string, [string, Tone]> = { merged: ["merged", "success"], not_merged: ["not merged", "failure"], unknown: ["merge state unknown", "attention"] };
|
|
260
|
+
/** `ahub sdlc merge <task> [--dry-run]` (#899): the hub's decision, every gate that refused it, the `gh` command, and GitHub's readback after a run. */
|
|
261
|
+
export function renderSdlcMerge(view: unknown, columns?: number, _now = Date.now()): Span[][] {
|
|
262
|
+
const v = view as any;
|
|
263
|
+
const w = 8, refused = list(v?.refused), argv = list(v?.argv);
|
|
264
|
+
const out: Line[] = [...heading(`Guarded merge of task #${v?.task ?? "unknown"}`, columns)];
|
|
265
|
+
out.push(...pair("mode", v?.mode ?? "unknown", columns, w, v?.mode === "off" ? "attention" : undefined));
|
|
266
|
+
if (v?.decision === "merge") out.push(...pair("decision", "merge: every gate passed", columns, w, "success"));
|
|
267
|
+
else if (v?.decision === "refused") out.push(...pair("decision", `refused by ${plural(refused.length, "gate")}`, columns, w, "attention"));
|
|
268
|
+
else out.push(...pair("decision", "unknown", columns, w, "attention"));
|
|
269
|
+
out.push(...bullets("refused", refused, columns, "attention", " "));
|
|
270
|
+
if (argv.length) out.push(...pair("command", `gh ${argv.join(" ")}`, columns, w));
|
|
271
|
+
const ran = v?.ran === true ? "yes" : v?.ran === false ? (v?.mode === "dry-run" && argv.length ? "no (dry run: the command above was not run)" : "no") : "unknown";
|
|
272
|
+
out.push(...pair("ran", ran, columns, w, v?.ran === true ? undefined : "muted"));
|
|
273
|
+
const r = v?.result;
|
|
274
|
+
if (r && typeof r === "object") {
|
|
275
|
+
const [state, tone] = MERGE_STATE[String(r.state)] ?? ["merge state unknown", "attention" as Tone];
|
|
276
|
+
out.push(blank, ...pair("result", `${state}, gh exit ${count(r.exit)}${r.mergeCommit ? `, merge commit ${short(r.mergeCommit)}` : ""}`, columns, w, tone));
|
|
277
|
+
if (r.cleanup) out.push(...pair("cleanup", `branch cleanup ${String(r.cleanup).replaceAll("_", " ")}${r.reason ? ` (${String(r.reason).replaceAll("_", " ")})` : ""}`, columns, w, r.cleanup === "deleted" ? undefined : "muted"));
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// macOS script(1) requires a terminal on its own stdin, including when its child gets a PTY.
|
|
2
|
+
// Keep a real PTY in this owned bridge instead; the outer Node child remains tracked by stopOwnedProcess.
|
|
3
|
+
const argv = process.argv.slice(2);
|
|
4
|
+
if (!argv.length) {
|
|
5
|
+
process.exitCode = 125;
|
|
6
|
+
} else {
|
|
7
|
+
let streamClosed!: () => void;
|
|
8
|
+
const eof = new Promise<void>(resolve => { streamClosed = resolve; });
|
|
9
|
+
const child = Bun.spawn(argv, {
|
|
10
|
+
cwd: process.cwd(), env: process.env,
|
|
11
|
+
terminal: {
|
|
12
|
+
cols: 120, rows: 40, name: "xterm-256color",
|
|
13
|
+
data: (_terminal, data) => { process.stdout.write(data); },
|
|
14
|
+
exit: () => streamClosed(),
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
const stop = () => { try { child.kill("SIGTERM"); } catch { /* exit readback decides */ } };
|
|
18
|
+
process.on("SIGINT", stop); process.on("SIGTERM", stop);
|
|
19
|
+
try {
|
|
20
|
+
const code = await child.exited;
|
|
21
|
+
// Drain the PTY before closing it; a descendant holding a slave cannot make the bridge wait forever.
|
|
22
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
23
|
+
await Promise.race([eof, new Promise<void>(resolve => { timer = setTimeout(resolve, 250); })]);
|
|
24
|
+
if (timer) clearTimeout(timer);
|
|
25
|
+
process.exitCode = code;
|
|
26
|
+
} catch {
|
|
27
|
+
process.exitCode = 125;
|
|
28
|
+
} finally {
|
|
29
|
+
child.terminal?.close();
|
|
30
|
+
process.removeListener("SIGINT", stop); process.removeListener("SIGTERM", stop);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { shellQuote } from "./shell-quote.ts";
|
|
4
|
+
import type { ChildProcess } from "node:child_process";
|
|
5
|
+
import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
6
|
+
|
|
7
|
+
export const MAX_PTY_OUTPUT_BYTES = 1024 * 1024;
|
|
8
|
+
const MIN_TIMEOUT_MS = 100;
|
|
9
|
+
const MAX_TIMEOUT_MS = 120_000;
|
|
10
|
+
const SCRIPT = "/usr/bin/script";
|
|
11
|
+
const PTY_COMMAND = '/bin/stty rows 40 cols 120 || exit 125; exec "$@"';
|
|
12
|
+
|
|
13
|
+
export interface PtyOptions {
|
|
14
|
+
cwd: string;
|
|
15
|
+
env: NodeJS.ProcessEnv;
|
|
16
|
+
timeoutMs: number;
|
|
17
|
+
onOutput?: (chunk: Buffer) => void;
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
ready?: () => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PtyResult {
|
|
23
|
+
outcome: "exited" | "completed" | "timeout" | "aborted" | "failed";
|
|
24
|
+
exitCode: number | null;
|
|
25
|
+
outputBytes: number;
|
|
26
|
+
elapsedMs: number;
|
|
27
|
+
cleaned: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Outcome = PtyResult["outcome"];
|
|
31
|
+
type Winner = { outcome: Outcome };
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
function commandFor(argv: readonly string[]): string[] {
|
|
35
|
+
const command = process.platform === "darwin"
|
|
36
|
+
? [process.execPath, join(import.meta.dir, "smoke-pty-bridge.ts"), ...argv]
|
|
37
|
+
: [SCRIPT, "-q", "-e", "-f", "-c", ["/bin/sh", "-c", PTY_COMMAND, "ahub-pty", ...argv].map(shellQuote).join(" "), "/dev/null"];
|
|
38
|
+
return command;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validate(argv: readonly string[], options: PtyOptions): void {
|
|
42
|
+
if (!Array.isArray(argv) || argv.length === 0 || argv.some(arg => typeof arg !== "string" || arg.includes("\0")) || !argv[0]) throw new Error("invalid PTY command");
|
|
43
|
+
if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < MIN_TIMEOUT_MS || options.timeoutMs > MAX_TIMEOUT_MS) throw new Error("invalid PTY timeout");
|
|
44
|
+
if (typeof options.cwd !== "string" || !options.cwd || !options.env || typeof options.env !== "object") throw new Error("invalid PTY environment");
|
|
45
|
+
if (process.platform !== "darwin" && process.platform !== "linux") throw new Error("PTY smoke runner is unsupported on this platform");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function waitForExit(proc: ChildProcess): Promise<{ code: number | null }> {
|
|
49
|
+
return new Promise(resolve => {
|
|
50
|
+
if (proc.exitCode !== null || proc.signalCode !== null) { resolve({ code: proc.exitCode }); return; }
|
|
51
|
+
proc.once("exit", code => resolve({ code }));
|
|
52
|
+
proc.once("error", () => resolve({ code: null }));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Run an argv vector inside a fixed-size PTY, always stopping its owned process group before returning. */
|
|
57
|
+
export async function runPty(argv: readonly string[], options: PtyOptions): Promise<PtyResult> {
|
|
58
|
+
validate(argv, options);
|
|
59
|
+
const started = Date.now();
|
|
60
|
+
if (options.signal?.aborted) return { outcome: "aborted", exitCode: null, outputBytes: 0, elapsedMs: Date.now() - started, cleaned: true };
|
|
61
|
+
|
|
62
|
+
let proc: ChildProcess;
|
|
63
|
+
try {
|
|
64
|
+
const command = commandFor(argv);
|
|
65
|
+
proc = spawn(command[0]!, command.slice(1), {
|
|
66
|
+
cwd: options.cwd,
|
|
67
|
+
env: { ...options.env, TERM: "xterm-256color" },
|
|
68
|
+
detached: true,
|
|
69
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
70
|
+
});
|
|
71
|
+
} catch {
|
|
72
|
+
throw new Error("PTY process could not be started");
|
|
73
|
+
}
|
|
74
|
+
trackGroup(proc);
|
|
75
|
+
const closed = new Promise<void>(resolve => proc.once("close", () => resolve()));
|
|
76
|
+
|
|
77
|
+
let outputBytes = 0, outputFailure: Error | undefined;
|
|
78
|
+
const drain = (chunk: Buffer) => {
|
|
79
|
+
outputBytes += chunk.length;
|
|
80
|
+
const room = Math.max(0, MAX_PTY_OUTPUT_BYTES - (outputBytes - chunk.length));
|
|
81
|
+
if (room > 0 && options.onOutput) {
|
|
82
|
+
try { options.onOutput(chunk.subarray(0, Math.min(room, chunk.length))); }
|
|
83
|
+
catch { outputFailure = new Error("PTY output handler failed"); }
|
|
84
|
+
}
|
|
85
|
+
if (outputBytes > MAX_PTY_OUTPUT_BYTES) outputFailure = new Error("PTY output exceeded 1 MiB");
|
|
86
|
+
};
|
|
87
|
+
proc.stdout?.on("data", drain);
|
|
88
|
+
proc.stderr?.on("data", drain);
|
|
89
|
+
|
|
90
|
+
let exitCode: number | null = null;
|
|
91
|
+
const exited = waitForExit(proc).then(value => { exitCode = value.code; return { outcome: "exited" as const }; });
|
|
92
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
93
|
+
let abortListener: (() => void) | undefined;
|
|
94
|
+
const interrupted = new Promise<Winner>(resolve => {
|
|
95
|
+
timer = setTimeout(() => resolve({ outcome: "timeout" }), options.timeoutMs);
|
|
96
|
+
options.signal?.addEventListener("abort", abortListener = () => resolve({ outcome: "aborted" }), { once: true });
|
|
97
|
+
if (options.signal?.aborted) resolve({ outcome: "aborted" });
|
|
98
|
+
});
|
|
99
|
+
const spawned = new Promise<void>((resolve, reject) => {
|
|
100
|
+
if (proc.pid !== undefined) { resolve(); return; }
|
|
101
|
+
proc.once("spawn", () => resolve());
|
|
102
|
+
proc.once("error", () => reject());
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
let winner: Winner;
|
|
106
|
+
let cleaned = false;
|
|
107
|
+
try {
|
|
108
|
+
const beforeStart = await Promise.race([spawned.then(() => ({ outcome: "started" as const })), interrupted]);
|
|
109
|
+
if (beforeStart.outcome !== "started") winner = beforeStart;
|
|
110
|
+
else if (proc.exitCode !== null || proc.signalCode !== null) winner = { outcome: "exited" };
|
|
111
|
+
else {
|
|
112
|
+
const ready = options.ready ? Promise.resolve().then(async (): Promise<Winner> => {
|
|
113
|
+
if (proc.exitCode !== null || proc.signalCode !== null) return { outcome: "exited" as const };
|
|
114
|
+
await options.ready!();
|
|
115
|
+
return { outcome: "completed" };
|
|
116
|
+
}).catch(() => ({ outcome: "failed" as const })) : undefined;
|
|
117
|
+
const failures = new Promise<Winner>(resolve => {
|
|
118
|
+
const check = () => { if (outputFailure) resolve({ outcome: "failed" }); };
|
|
119
|
+
proc.stdout?.on("data", check);
|
|
120
|
+
proc.stderr?.on("data", check);
|
|
121
|
+
check();
|
|
122
|
+
});
|
|
123
|
+
winner = await Promise.race([exited, interrupted, failures, ...(ready ? [ready] : [])]);
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
winner = { outcome: "failed" };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (timer) clearTimeout(timer);
|
|
130
|
+
if (abortListener) options.signal?.removeEventListener("abort", abortListener);
|
|
131
|
+
if (proc.pid === undefined) await spawned.catch(() => {});
|
|
132
|
+
try {
|
|
133
|
+
await stopOwnedProcess(proc, { group: true });
|
|
134
|
+
cleaned = true;
|
|
135
|
+
} catch {
|
|
136
|
+
cleaned = false;
|
|
137
|
+
}
|
|
138
|
+
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
139
|
+
await Promise.race([closed, new Promise<void>(resolve => { closeTimer = setTimeout(resolve, 250); })]);
|
|
140
|
+
if (closeTimer) clearTimeout(closeTimer);
|
|
141
|
+
// The owned-group stop drops pipes, but the close event is only a bounded best-effort drain barrier.
|
|
142
|
+
if (outputFailure) winner = { outcome: "failed" };
|
|
143
|
+
if (!cleaned && winner.outcome !== "timeout" && winner.outcome !== "aborted") winner = { outcome: "failed" };
|
|
144
|
+
return { outcome: winner.outcome, exitCode: exitCode ?? proc.exitCode, outputBytes, elapsedMs: Date.now() - started, cleaned };
|
|
145
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { closeSync, constants, existsSync, fstatSync, mkdirSync, mkdtempSync, openSync, readSync, rmSync, writeFileSync, writeSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { assessVersion } from "../adapters/compat.ts";
|
|
4
|
+
import { hubGitSync } from "../hub/git.ts";
|
|
5
|
+
import { realPath } from "../hub/project.ts";
|
|
6
|
+
import { runPty } from "./smoke-pty.ts";
|
|
7
|
+
import type { SmokeTarget } from "./smoke-target.ts";
|
|
8
|
+
|
|
9
|
+
export interface SmokeReceipt {
|
|
10
|
+
schemaVersion: 1;
|
|
11
|
+
leg: "qwen-dual-output";
|
|
12
|
+
outcome: "passed" | "failed" | "timeout";
|
|
13
|
+
reason: "complete" | "protocol" | "timeout" | "cleanup" | "aborted";
|
|
14
|
+
checks: { disposable: boolean; protocol: boolean; queued: boolean; cleaned: boolean };
|
|
15
|
+
counts: { submits: number; users: number; assistants: number; stops: number; events: number; terminalBytes: number };
|
|
16
|
+
timings: { totalMs: number; protocolMs: number | null };
|
|
17
|
+
}
|
|
18
|
+
export interface SmokeOptions { leg: "qwen-dual-output"; receipt: string; timeoutMs: number; signal?: AbortSignal }
|
|
19
|
+
/** Host-only test ports, never loaded from project configuration or accepted as CLI arguments. */
|
|
20
|
+
export interface SmokePorts { command?: readonly string[]; env?: NodeJS.ProcessEnv }
|
|
21
|
+
const FIRST = "AHUB_SMOKE_FIRST", SECOND = "AHUB_SMOKE_SECOND";
|
|
22
|
+
const FIRST_PROMPT = `Print ${FIRST} once, then list 64 short numbered lines. Do not use any tools.`;
|
|
23
|
+
const SECOND_PROMPT = `Reply with exactly ${SECOND}. Do not use any tools.`;
|
|
24
|
+
const protocolError = () => new Error("smoke protocol refused");
|
|
25
|
+
const inside = (root: string, value: string) => { const name = relative(root, value); return !!name && !isAbsolute(name) && name !== ".." && !name.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`); };
|
|
26
|
+
|
|
27
|
+
/** No model-selected commands, prompts or persisted output: this is one fixed Q2 protocol leg. */
|
|
28
|
+
export async function runSmoke(target: SmokeTarget, options: SmokeOptions, ports: SmokePorts = {}): Promise<SmokeReceipt> {
|
|
29
|
+
target.check();
|
|
30
|
+
if (options.leg !== "qwen-dual-output" || !Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 100 || options.timeoutMs > 120_000) throw new Error("smoke arguments refused");
|
|
31
|
+
const receiptPath = resolve(target.cwd, options.receipt), parent = dirname(receiptPath);
|
|
32
|
+
if (!inside(target.cwd, receiptPath) || parent !== target.cwd || realPath(parent) !== parent || existsSync(receiptPath)) throw new Error("smoke receipt refused");
|
|
33
|
+
const source = ports.env ?? process.env;
|
|
34
|
+
if (!source.OPENAI_API_KEY?.trim() || !source.OPENAI_BASE_URL?.trim() || !source.OPENAI_MODEL?.trim()) throw new Error("smoke prerequisites unavailable");
|
|
35
|
+
let endpoint: URL;
|
|
36
|
+
try { endpoint = new URL(source.OPENAI_BASE_URL); } catch { throw new Error("smoke prerequisites unavailable"); }
|
|
37
|
+
if (endpoint.username || endpoint.password || !["https:", "http:"].includes(endpoint.protocol)) throw new Error("smoke prerequisites unavailable");
|
|
38
|
+
const command = ports.command ?? [Bun.which("qwen", { PATH: source.PATH }) ?? ""];
|
|
39
|
+
if (!command.length || !command[0]) throw new Error("smoke prerequisites unavailable");
|
|
40
|
+
const started = performance.now();
|
|
41
|
+
const receipt: SmokeReceipt = { schemaVersion: 1, leg: "qwen-dual-output", outcome: "failed", reason: "protocol", checks: { disposable: true, protocol: false, queued: false, cleaned: true }, counts: { submits: 0, users: 0, assistants: 0, stops: 0, events: 0, terminalBytes: 0 }, timings: { totalMs: 0, protocolMs: null } };
|
|
42
|
+
target.check();
|
|
43
|
+
const run = mkdtempSync(join(target.cwd, ".smoke-run-"));
|
|
44
|
+
const home = join(run, "home"), cwd = join(run, "project"), temporary = join(run, "tmp");
|
|
45
|
+
for (const dir of [home, cwd, temporary, join(home, ".qwen")]) mkdirSync(dir, { mode: 0o700 });
|
|
46
|
+
let cleaned = true, protocolMs: number | null = null;
|
|
47
|
+
try {
|
|
48
|
+
const git = hubGitSync(["-c", "init.templateDir=", "init", "-q", cwd], { encoding: "utf8" });
|
|
49
|
+
if (git.status !== 0) throw protocolError();
|
|
50
|
+
writeFileSync(join(home, ".qwen", "settings.json"), JSON.stringify({ security: { auth: { selectedType: "openai" } }, general: { disableAutoUpdate: true, disableUpdateNag: true }, privacy: { usageStatisticsEnabled: false }, telemetry: { enabled: false }, mcpServers: {} }), { mode: 0o600, flag: "wx" });
|
|
51
|
+
const input = join(run, "input.jsonl"), events = join(run, "events.jsonl"), mcp = join(run, "mcp.json");
|
|
52
|
+
for (const file of [input, events]) writeFileSync(file, "", { mode: 0o600, flag: "wx" });
|
|
53
|
+
writeFileSync(mcp, JSON.stringify({ mcpServers: {} }), { mode: 0o600, flag: "wx" });
|
|
54
|
+
const env: NodeJS.ProcessEnv = { HOME: home, XDG_CONFIG_HOME: join(home, ".config"), XDG_CACHE_HOME: join(home, ".cache"), XDG_DATA_HOME: join(home, ".local", "share"), PATH: source.PATH ?? "", TERM: "xterm-256color", LANG: "en_US.UTF-8", TMPDIR: temporary, OPENAI_API_KEY: source.OPENAI_API_KEY, OPENAI_BASE_URL: source.OPENAI_BASE_URL, OPENAI_MODEL: source.OPENAI_MODEL, QWEN_USAGE_STATISTICS_ENABLED: "false", NO_COLOR: "1" };
|
|
55
|
+
const fd = openSync(events, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
56
|
+
const inputFd = openSync(input, constants.O_WRONLY | constants.O_APPEND | constants.O_NOFOLLOW);
|
|
57
|
+
const decoder = new TextDecoder();
|
|
58
|
+
let position = 0, partial = "", announced = false, assistant = false, queued = false, queueCandidate = false;
|
|
59
|
+
const protocolStop = new AbortController();
|
|
60
|
+
const protocolStarted = performance.now();
|
|
61
|
+
const deadline = protocolStarted + options.timeoutMs;
|
|
62
|
+
const submit = (text: string, window?: () => boolean) => { target.check(); if (window && !window()) return false; if (!fstatSync(inputFd).isFile() || fstatSync(inputFd).nlink !== 1) throw protocolError(); writeSync(inputFd, JSON.stringify({ type: "submit", text }) + "\n"); receipt.counts.submits++; return true; };
|
|
63
|
+
try {
|
|
64
|
+
target.check(); cleaned = false; receipt.checks.cleaned = false;
|
|
65
|
+
const result = await runPty([...command, "--input-file", input, "--json-file", events, "--mcp-config", mcp, "--approval-mode", "default", "--auth-type", "openai", "--model", source.OPENAI_MODEL], { cwd, env, timeoutMs: options.timeoutMs, signal: options.signal, ready: async () => {
|
|
66
|
+
try {
|
|
67
|
+
while (performance.now() < deadline && !options.signal?.aborted && !protocolStop.signal.aborted) {
|
|
68
|
+
const info = fstatSync(fd);
|
|
69
|
+
if (!info.isFile() || info.nlink !== 1 || info.size < position || info.size > 8 * 1024 * 1024) throw protocolError();
|
|
70
|
+
if (position < info.size) {
|
|
71
|
+
const chunk = Buffer.alloc(Math.min(64 * 1024, info.size - position));
|
|
72
|
+
const got = readSync(fd, chunk, 0, chunk.length, position); position += got;
|
|
73
|
+
partial += decoder.decode(chunk.subarray(0, got), { stream: true });
|
|
74
|
+
if (Buffer.byteLength(partial) > 128 * 1024) throw protocolError();
|
|
75
|
+
let newline: number;
|
|
76
|
+
while ((newline = partial.indexOf("\n")) >= 0) {
|
|
77
|
+
const line = partial.slice(0, newline); partial = partial.slice(newline + 1);
|
|
78
|
+
if (!line.trim()) continue;
|
|
79
|
+
if (Buffer.byteLength(line) > 64 * 1024) throw protocolError();
|
|
80
|
+
let event: any; try { event = JSON.parse(line); } catch { throw protocolError(); }
|
|
81
|
+
receipt.counts.events++;
|
|
82
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) throw protocolError();
|
|
83
|
+
if (event.type === "control_request") throw protocolError(); // this read-only leg never answers approvals
|
|
84
|
+
if (event.type === "system" && event.subtype === "session_start") {
|
|
85
|
+
if (announced || event.data?.protocol_version !== 2 || !assessVersion("qwen-dual", event.data?.version).ok || !Array.isArray(event.data?.supported_events) || !["user", "assistant", "stream_event"].every(name => event.data.supported_events.includes(name))) throw protocolError();
|
|
86
|
+
announced = true; submit(FIRST_PROMPT);
|
|
87
|
+
} else if (event.type === "user") {
|
|
88
|
+
if (!announced || receipt.counts.users >= receipt.counts.submits || receipt.counts.users === 1 && receipt.counts.stops !== 1) throw protocolError();
|
|
89
|
+
const content = event.message?.content;
|
|
90
|
+
const text = typeof content === "string" ? content : Array.isArray(content) ? content.filter((b: any) => b?.type === "text" && typeof b.text === "string").map((b: any) => b.text).join("") : "";
|
|
91
|
+
if (text.trim() !== (receipt.counts.users === 0 ? FIRST_PROMPT : SECOND_PROMPT)) throw protocolError();
|
|
92
|
+
receipt.counts.users++;
|
|
93
|
+
} else if (event.type === "assistant") {
|
|
94
|
+
const blocks = event.message?.content;
|
|
95
|
+
const text = Array.isArray(blocks) ? blocks.filter((b: any) => b?.type === "text" && typeof b.text === "string").map((b: any) => b.text).join("") : "";
|
|
96
|
+
if (event.message?.stop_reason === "tool_use" || !text.includes(receipt.counts.stops === 0 ? FIRST : SECOND) || receipt.counts.users !== receipt.counts.stops + 1 || assistant) throw protocolError();
|
|
97
|
+
assistant = true; receipt.counts.assistants++;
|
|
98
|
+
} else if (event.type === "stream_event") {
|
|
99
|
+
if (!event.event || typeof event.event.type !== "string") throw protocolError();
|
|
100
|
+
if (event.event?.type === "message_stop") {
|
|
101
|
+
if (!assistant || receipt.counts.stops === 0 && !queued) throw protocolError();
|
|
102
|
+
assistant = false; receipt.counts.stops++;
|
|
103
|
+
if (receipt.counts.stops === 2) { receipt.checks.protocol = true; receipt.checks.queued = queued; return; }
|
|
104
|
+
} else if (receipt.counts.users === 1 && !queued && ["message_start", "content_block_start", "content_block_delta"].includes(event.event.type)) {
|
|
105
|
+
queueCandidate = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Consume everything already published before calling a still-running turn queued.
|
|
110
|
+
if (position < info.size) continue;
|
|
111
|
+
if (queueCandidate && !queued && partial.length === 0) {
|
|
112
|
+
if (submit(SECOND_PROMPT, () => fstatSync(fd).size === position)) { queued = true; queueCandidate = false; }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
await Bun.sleep(20);
|
|
116
|
+
}
|
|
117
|
+
throw protocolError();
|
|
118
|
+
} finally { protocolMs = Math.round(performance.now() - protocolStarted); }
|
|
119
|
+
} });
|
|
120
|
+
receipt.counts.terminalBytes = result.outputBytes;
|
|
121
|
+
receipt.checks.cleaned = result.cleaned;
|
|
122
|
+
cleaned = result.cleaned;
|
|
123
|
+
if (result.outcome === "completed" && receipt.checks.protocol && receipt.checks.queued && result.cleaned) { receipt.outcome = "passed"; receipt.reason = "complete"; }
|
|
124
|
+
else if (result.outcome === "timeout" || !receipt.checks.protocol && performance.now() >= deadline) { receipt.outcome = "timeout"; receipt.reason = "timeout"; }
|
|
125
|
+
else if (result.outcome === "aborted") receipt.reason = "aborted";
|
|
126
|
+
} finally { protocolStop.abort(); closeSync(fd); closeSync(inputFd); }
|
|
127
|
+
} catch { receipt.reason = "protocol"; }
|
|
128
|
+
finally {
|
|
129
|
+
// Remove only the directory this invocation made, after all owned processes were confirmed stopped.
|
|
130
|
+
if (cleaned) rmSync(run, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
receipt.timings.totalMs = Math.round(performance.now() - started);
|
|
133
|
+
receipt.timings.protocolMs = protocolMs;
|
|
134
|
+
if (!receipt.checks.cleaned) { receipt.outcome = "failed"; receipt.reason = "cleanup"; }
|
|
135
|
+
target.check();
|
|
136
|
+
try { writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + "\n", { mode: 0o600, flag: "wx" }); }
|
|
137
|
+
catch { throw new Error("smoke receipt refused"); }
|
|
138
|
+
return receipt;
|
|
139
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { lstatSync, type Stats } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
4
|
+
import type { Project } from "../hub/registry.ts";
|
|
5
|
+
import { hubGitSync } from "../hub/git.ts";
|
|
6
|
+
import { realPath } from "../hub/project.ts";
|
|
7
|
+
|
|
8
|
+
const REFUSAL = "smoke target refused";
|
|
9
|
+
|
|
10
|
+
export interface SmokeTarget {
|
|
11
|
+
cwd: string;
|
|
12
|
+
hubHome: string;
|
|
13
|
+
stateDir: string;
|
|
14
|
+
projectId: string;
|
|
15
|
+
check(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Identity = { dev: number; ino: number };
|
|
19
|
+
type Snapshot = { cwd: string; hubHome: string; stateDir: string; projectId: string; identities: { cwd: Identity; git: Identity; home: Identity; state: Identity } };
|
|
20
|
+
|
|
21
|
+
function refuse(): never { throw new Error(REFUSAL); }
|
|
22
|
+
|
|
23
|
+
function realDirectory(path: string): { path: string; stat: Stats } {
|
|
24
|
+
if (!isAbsolute(path)) refuse();
|
|
25
|
+
assertNoSymlink(path);
|
|
26
|
+
const canonical = realPath(path);
|
|
27
|
+
const stat = lstatSync(canonical);
|
|
28
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) refuse();
|
|
29
|
+
return { path: canonical, stat };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Check the caller's spelling too: resolving a symlink before the check would hide a redirected path. */
|
|
33
|
+
function assertNoSymlink(path: string): void {
|
|
34
|
+
if (path.split(sep).includes("..")) refuse();
|
|
35
|
+
const absolute = resolve(path);
|
|
36
|
+
const root = parse(absolute).root;
|
|
37
|
+
let current = root;
|
|
38
|
+
for (const segment of absolute.slice(root.length).split(sep).filter(Boolean)) {
|
|
39
|
+
current = join(current, segment);
|
|
40
|
+
const stat = lstatSync(current);
|
|
41
|
+
if (stat.isSymbolicLink()) refuse();
|
|
42
|
+
}
|
|
43
|
+
if (lstatSync(root).isSymbolicLink()) refuse();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function inside(root: string, child: string): boolean {
|
|
47
|
+
const rel = relative(root, child);
|
|
48
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function identity(stat: Stats): Identity { return { dev: stat.dev, ino: stat.ino }; }
|
|
52
|
+
function sameIdentity(a: Identity, b: Identity): boolean { return a.dev === b.dev && a.ino === b.ino; }
|
|
53
|
+
|
|
54
|
+
function gitPath(cwd: string, args: string[]): string {
|
|
55
|
+
const result = hubGitSync(["-C", cwd, ...args], { encoding: "utf8", timeout: 2000, maxBuffer: 64 * 1024 });
|
|
56
|
+
if (result.status !== 0) refuse();
|
|
57
|
+
const path = result.stdout.trim();
|
|
58
|
+
if (!path) refuse();
|
|
59
|
+
return realPath(path);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function inspect(input: {
|
|
63
|
+
cwd: string;
|
|
64
|
+
hubHome: string;
|
|
65
|
+
project: Pick<Project, "id" | "root" | "stateDir">;
|
|
66
|
+
expectedProjectId: string;
|
|
67
|
+
liveTest: boolean;
|
|
68
|
+
}): Snapshot {
|
|
69
|
+
if (input.liveTest !== true || !input.expectedProjectId || input.project.id !== input.expectedProjectId) refuse();
|
|
70
|
+
|
|
71
|
+
const root = realDirectory(input.cwd);
|
|
72
|
+
const registeredRoot = realDirectory(input.project.root);
|
|
73
|
+
if (root.path !== registeredRoot.path) refuse();
|
|
74
|
+
|
|
75
|
+
const tempRoot = realPath(tmpdir());
|
|
76
|
+
const temp = realDirectory(tempRoot);
|
|
77
|
+
if (dirname(root.path) !== temp.path || !basename(root.path).startsWith("ahub-smoke-") || root.path === temp.path) refuse();
|
|
78
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
79
|
+
if (uid !== undefined && root.stat.uid !== uid) refuse();
|
|
80
|
+
|
|
81
|
+
const gitDirPath = join(root.path, ".git");
|
|
82
|
+
const gitDir = lstatSync(gitDirPath);
|
|
83
|
+
if (gitDir.isSymbolicLink() || !gitDir.isDirectory()) refuse();
|
|
84
|
+
if (gitPath(root.path, ["rev-parse", "--show-toplevel"]) !== root.path) refuse();
|
|
85
|
+
if (gitPath(root.path, ["rev-parse", "--path-format=absolute", "--git-common-dir"]) !== gitDirPath) refuse();
|
|
86
|
+
|
|
87
|
+
// ponytail: first slice accepts an uncommitted, untracked scratch index only; a future explicit disposable manifest can admit fixture histories.
|
|
88
|
+
for (const args of [["for-each-ref", "--count=1", "--format=%(refname)"], ["ls-files"], ["remote"]]) {
|
|
89
|
+
const result = hubGitSync(["-C", root.path, ...args], { encoding: "utf8", timeout: 2000, maxBuffer: 64 * 1024 });
|
|
90
|
+
if (result.status !== 0 || result.stdout.trim()) refuse();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const home = realDirectory(input.hubHome);
|
|
94
|
+
const state = realDirectory(input.project.stateDir);
|
|
95
|
+
if (!inside(root.path, home.path) || !inside(root.path, state.path)) refuse();
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
cwd: root.path,
|
|
99
|
+
hubHome: home.path,
|
|
100
|
+
stateDir: state.path,
|
|
101
|
+
projectId: input.project.id,
|
|
102
|
+
identities: { cwd: identity(root.stat), git: identity(gitDir), home: identity(home.stat), state: identity(state.stat) },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function validateSmokeTarget(input: {
|
|
107
|
+
cwd: string;
|
|
108
|
+
hubHome: string;
|
|
109
|
+
project: Pick<Project, "id" | "root" | "stateDir">;
|
|
110
|
+
expectedProjectId: string;
|
|
111
|
+
liveTest: boolean;
|
|
112
|
+
}): SmokeTarget {
|
|
113
|
+
let original: Snapshot;
|
|
114
|
+
try { original = inspect(input); } catch { return refuse(); }
|
|
115
|
+
return {
|
|
116
|
+
cwd: original.cwd,
|
|
117
|
+
hubHome: original.hubHome,
|
|
118
|
+
stateDir: original.stateDir,
|
|
119
|
+
projectId: original.projectId,
|
|
120
|
+
check() {
|
|
121
|
+
try {
|
|
122
|
+
const current = inspect(input);
|
|
123
|
+
if (current.cwd !== original.cwd || current.hubHome !== original.hubHome || current.stateDir !== original.stateDir
|
|
124
|
+
|| current.projectId !== original.projectId || !sameIdentity(current.identities.cwd, original.identities.cwd)
|
|
125
|
+
|| !sameIdentity(current.identities.git, original.identities.git) || !sameIdentity(current.identities.home, original.identities.home)
|
|
126
|
+
|| !sameIdentity(current.identities.state, original.identities.state)) refuse();
|
|
127
|
+
} catch { refuse(); }
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Human output for `ahub source add|list` (#508). Pure: data in, spans out, no I/O. `--json` is the caller's branch and prints the same data.
|
|
2
|
+
import { blank, grid, heading, pair, s } from "./sdlc-output.ts";
|
|
3
|
+
import { moment } from "./sdlc-output.ts";
|
|
4
|
+
import type { Span } from "./console-state.ts";
|
|
5
|
+
|
|
6
|
+
const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
|
|
7
|
+
const size = (bytes: unknown) => typeof bytes === "number" && Number.isFinite(bytes) ? bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KiB` : `${(bytes / 1024 / 1024).toFixed(1)} MiB` : "unknown";
|
|
8
|
+
|
|
9
|
+
/** `ahub source add`: what was registered, and whether it was new or an upgrade of an existing record. */
|
|
10
|
+
export function renderSourceAdd(result: unknown, columns?: number): Span[][] {
|
|
11
|
+
const r = result as any, out: Span[][] = [...heading(r?.created ? `Registered ${r?.id}` : r?.upgraded ? `Already registered ${r?.id}; now private` : `Already registered ${r?.id}`, columns)];
|
|
12
|
+
out.push(...pair("kind", r?.kind ?? "unknown", columns, 8), ...pair("origin", r?.origin ?? "unknown", columns, 8), ...pair("sha256", r?.sha256 ?? "unknown", columns, 8), ...pair("size", size(r?.bytes), columns, 8));
|
|
13
|
+
out.push(...pair("private", r?.private ? "yes" : "no", columns, 8, r?.private ? "attention" : undefined), ...pair("text", r?.text ? "projected" : "none (no converter handles these bytes)", columns, 8, r?.text ? undefined : "muted"));
|
|
14
|
+
if (list(r?.reasons).length) out.push(...pair("because", list(r.reasons).join("; "), columns, 8, "attention"));
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `ahub source list`: one row per registered source, newest last. The person sees every origin of a private source. */
|
|
19
|
+
export function renderSourceList(rows: unknown, columns?: number, now = Date.now()): Span[][] {
|
|
20
|
+
const sources = list(rows);
|
|
21
|
+
if (!sources.length) return [[s("no sources registered; add one with ahub source add <path|url>", "muted")]];
|
|
22
|
+
const out: Span[][] = [...heading(`Sources (${sources.length})`, columns)];
|
|
23
|
+
out.push(...grid(["ID", "KIND", "SIZE", "PRIVATE", "ADDED", "ORIGIN"], sources.map((row: any) => [
|
|
24
|
+
s(row.id, "issueRef"), s(row.kind), s(size(row.bytes)), s(row.private ? "yes" : "no", row.private ? "attention" : "muted"), s(moment(row.added_at, now)), s(row.origin),
|
|
25
|
+
]), columns));
|
|
26
|
+
const many = sources.filter((row: any) => list(row.origins).length > 1);
|
|
27
|
+
for (const row of many) out.push(blank, ...pair(row.id, `${list(row.origins).length} origins: ${list(row.origins).map((o: any) => `${o.origin}${o.private ? " (private)" : ""}`).join("; ")}`, columns, 20, undefined));
|
|
28
|
+
return out;
|
|
29
|
+
}
|
package/src/cli/task-output.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// and detail, empty refs and plan): this module shows that stub and never asks for more.
|
|
6
6
|
import { type Span, type Tone, plural, table, terminalText, wrap } from "./console-state.ts";
|
|
7
7
|
import { moment } from "./sdlc-output.ts";
|
|
8
|
+
import { taskHolderText } from "./role-holders.ts";
|
|
8
9
|
|
|
9
10
|
type Line = Span[];
|
|
10
11
|
// Local copies of the layout primitives of sdlc-output.ts (#814 T1), the source of these helpers: that file stays untouched by this task
|
|
@@ -100,8 +101,8 @@ function taskBlock(task: any, columns: number | undefined, now: number, detail:
|
|
|
100
101
|
const out: Line[] = [...para(`${id} ${dash(task?.title)}`, columns, undefined, "", 6).map((line, i): Line => i === 0 ? [s(id, "taskRef"), s(line[0]!.text.slice(id.length), "strong")] : line)];
|
|
101
102
|
const w = 10;
|
|
102
103
|
out.push(...pair("state", `${dash(task?.state)}${task?.cancelled ? " (cancelled)" : ""}`, columns, w, stateTone(task?.state), " "),
|
|
103
|
-
...pair("class", dash(task?.class), columns, w, undefined, " "), ...pair("owner",
|
|
104
|
-
if (task?.planner) out.push(...pair("planner", task
|
|
104
|
+
...pair("class", dash(task?.class), columns, w, undefined, " "), ...pair("owner", taskHolderText(task, "owner"), columns, w, undefined, " "), ...pair("reviewer", taskHolderText(task, "reviewer"), columns, w, undefined, " "));
|
|
105
|
+
if (task?.planner !== undefined) out.push(...pair("planner", taskHolderText(task, "planner"), columns, w, undefined, " "));
|
|
105
106
|
if (task?.reserved) out.push(...pair("reserved", task.reserved, columns, w, undefined, " "));
|
|
106
107
|
out.push(...pair("after", ids(task?.deps), columns, w, undefined, " "));
|
|
107
108
|
if (typeof task?.release === "number") out.push(...pair("release", `#${task.release}`, columns, w, undefined, " "));
|