harnery 0.44.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commander.js +1 -0
- package/dist/commands/files.d.ts +2 -2
- package/dist/commands/files.d.ts.map +1 -1
- package/dist/commands/files.js +7 -4
- package/dist/commands/servers.d.ts.map +1 -1
- package/dist/commands/servers.js +11 -17
- package/dist/commands/tunnel.d.ts +15 -0
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +453 -153
- package/dist/commands/turn-rollback.d.ts +5 -0
- package/dist/commands/turn-rollback.d.ts.map +1 -0
- package/dist/commands/turn-rollback.js +76 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +44 -0
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +18 -0
- package/dist/core/agents/state/proc-start.d.ts +5 -6
- package/dist/core/agents/state/proc-start.d.ts.map +1 -1
- package/dist/core/agents/state/proc-start.js +13 -9
- package/dist/core/agents/state/process-handle.d.ts +26 -0
- package/dist/core/agents/state/process-handle.d.ts.map +1 -0
- package/dist/core/agents/state/process-handle.js +121 -0
- package/dist/core/artifacts/delivery-card.d.ts.map +1 -1
- package/dist/core/artifacts/delivery-card.js +2 -1
- package/dist/core/resources/service.d.ts +2 -16
- package/dist/core/resources/service.d.ts.map +1 -1
- package/dist/core/resources/service.js +1 -78
- package/dist/core/semantic/service.d.ts.map +1 -1
- package/dist/core/semantic/service.js +2 -9
- package/dist/core/servers/index.d.ts +12 -11
- package/dist/core/servers/index.d.ts.map +1 -1
- package/dist/core/servers/index.js +194 -81
- package/dist/core/servers/tunnels.d.ts +5 -3
- package/dist/core/servers/tunnels.d.ts.map +1 -1
- package/dist/core/servers/tunnels.js +45 -2
- package/dist/core/supervisor/service.d.ts +2 -4
- package/dist/core/supervisor/service.d.ts.map +1 -1
- package/dist/core/turn-rollback/content.d.ts +53 -0
- package/dist/core/turn-rollback/content.d.ts.map +1 -0
- package/dist/core/turn-rollback/content.js +396 -0
- package/dist/core/turn-rollback/index.d.ts +72 -0
- package/dist/core/turn-rollback/index.d.ts.map +1 -0
- package/dist/core/turn-rollback/index.js +443 -0
- package/dist/core/workflow/index.d.ts +4 -2
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +2 -1
- package/dist/core/workflow/recovery-audit.d.ts +30 -0
- package/dist/core/workflow/recovery-audit.d.ts.map +1 -0
- package/dist/core/workflow/recovery-audit.js +116 -0
- package/dist/core/workflow/run-state.d.ts +19 -2
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +113 -8
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.js +70 -14
- package/dist/core/workflow/workspaces/reclaim.d.ts +6 -1
- package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/recovery.d.ts +2 -0
- package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/recovery.js +24 -0
- package/dist/lib/tunnel/state.d.ts +13 -6
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +58 -12
- package/package.json +6 -1
- package/src/commander.ts +10 -0
- package/src/commands/files.ts +13 -5
- package/src/commands/servers.ts +12 -23
- package/src/commands/tunnel.ts +533 -166
- package/src/commands/turn-rollback.ts +111 -0
- package/src/commands/workflow.ts +59 -0
- package/src/core/agents/coord-client.ts +20 -0
- package/src/core/agents/state/proc-start.ts +14 -11
- package/src/core/agents/state/process-handle.ts +165 -0
- package/src/core/artifacts/delivery-card.ts +7 -1
- package/src/core/resources/service.ts +3 -88
- package/src/core/semantic/service.ts +2 -8
- package/src/core/servers/index.ts +234 -85
- package/src/core/servers/tunnels.ts +55 -3
- package/src/core/supervisor/service.ts +2 -3
- package/src/core/turn-rollback/content.ts +499 -0
- package/src/core/turn-rollback/index.ts +580 -0
- package/src/core/workflow/index.ts +12 -1
- package/src/core/workflow/recovery-audit.ts +181 -0
- package/src/core/workflow/run-state.ts +137 -8
- package/src/core/workflow/workspaces/local-git.ts +86 -15
- package/src/core/workflow/workspaces/reclaim.ts +4 -0
- package/src/core/workflow/workspaces/recovery.ts +23 -0
- package/src/lib/tunnel/state.ts +70 -15
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import type { EmitContext, HarneryProgramContext } from "../commander.ts";
|
|
3
|
+
import { monorepoRoot } from "../core/agents/index.ts";
|
|
4
|
+
import {
|
|
5
|
+
applyTurnRollback,
|
|
6
|
+
beginTurnRollback,
|
|
7
|
+
inspectTurnRollback,
|
|
8
|
+
prepareTurnRollback,
|
|
9
|
+
sealTurnRollback,
|
|
10
|
+
type TurnRollbackPlan,
|
|
11
|
+
} from "../core/turn-rollback/index.ts";
|
|
12
|
+
import { readJsonRecord } from "../core/workflow/durable-record.ts";
|
|
13
|
+
import { readWorkspaceBinding } from "../core/workflow/workspaces/state.ts";
|
|
14
|
+
|
|
15
|
+
/** Explicit turn boundaries; hooks cannot infer provider ownership or path scope. */
|
|
16
|
+
export function registerTurnRollbackCommand(
|
|
17
|
+
program: Command,
|
|
18
|
+
emit: EmitContext,
|
|
19
|
+
context?: HarneryProgramContext,
|
|
20
|
+
): void {
|
|
21
|
+
const command = program
|
|
22
|
+
.command("turn-rollback")
|
|
23
|
+
.description("Capture and review exact-path content rollback in an owned workspace");
|
|
24
|
+
type Options = {
|
|
25
|
+
binding: string;
|
|
26
|
+
turn?: string;
|
|
27
|
+
generation?: string;
|
|
28
|
+
path?: string[];
|
|
29
|
+
yes?: boolean;
|
|
30
|
+
actor?: string;
|
|
31
|
+
reason?: string;
|
|
32
|
+
plan?: string;
|
|
33
|
+
};
|
|
34
|
+
const run =
|
|
35
|
+
(operation: (coordRoot: string, options: Options) => unknown | Promise<unknown>) =>
|
|
36
|
+
async (options: Options) => {
|
|
37
|
+
try {
|
|
38
|
+
const coordRoot = context?.resolveCoordRoot?.() ?? context?.repoRoot ?? monorepoRoot();
|
|
39
|
+
if (!coordRoot) throw new Error("project coordination root is unavailable");
|
|
40
|
+
emit.config({ format: "json" });
|
|
41
|
+
const result = await operation(coordRoot, options);
|
|
42
|
+
emit.data(result);
|
|
43
|
+
if (
|
|
44
|
+
result &&
|
|
45
|
+
typeof result === "object" &&
|
|
46
|
+
"status" in result &&
|
|
47
|
+
result.status === "unknown"
|
|
48
|
+
)
|
|
49
|
+
emit.setExitCode(1);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
emit.error({ code: "turn_rollback_refused", message: String(error) });
|
|
52
|
+
emit.setExitCode(1);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const binding = (root: string, options: Options) => {
|
|
56
|
+
const value = readWorkspaceBinding(root, "local-git-worktree", options.binding);
|
|
57
|
+
if (!value) throw new Error("immutable workspace binding was not found");
|
|
58
|
+
return value;
|
|
59
|
+
};
|
|
60
|
+
command
|
|
61
|
+
.command("begin")
|
|
62
|
+
.description("Capture exact literal paths before a turn")
|
|
63
|
+
.requiredOption("--binding <id>", "Owned workspace binding ID")
|
|
64
|
+
.requiredOption("--turn <id>", "Caller turn identity")
|
|
65
|
+
.requiredOption("--generation <number>", "Increasing turn generation")
|
|
66
|
+
.requiredOption("--path <file...>", "Exact relative file paths, including planned new files")
|
|
67
|
+
.action(
|
|
68
|
+
run((root, options) =>
|
|
69
|
+
beginTurnRollback(root, {
|
|
70
|
+
binding: binding(root, options),
|
|
71
|
+
turn: { turn_id: options.turn!, generation: Number(options.generation) },
|
|
72
|
+
paths: options.path!,
|
|
73
|
+
}),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
for (const operation of ["seal", "prepare", "show"] as const) {
|
|
77
|
+
command
|
|
78
|
+
.command(`${operation} <capture-id>`)
|
|
79
|
+
.description(`${operation} an exact turn capture`)
|
|
80
|
+
.requiredOption("--binding <id>", "Owned workspace binding ID")
|
|
81
|
+
.action((id: string, options: Options) =>
|
|
82
|
+
run((root, opts) => {
|
|
83
|
+
const input = { binding: binding(root, opts), capture_id: id };
|
|
84
|
+
return operation === "seal"
|
|
85
|
+
? sealTurnRollback(root, input)
|
|
86
|
+
: operation === "prepare"
|
|
87
|
+
? prepareTurnRollback(root, input)
|
|
88
|
+
: inspectTurnRollback(root, input);
|
|
89
|
+
})(options),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
command
|
|
93
|
+
.command("apply")
|
|
94
|
+
.description("Preview a saved proposal, or apply it after exact confirmation")
|
|
95
|
+
.requiredOption("--binding <id>", "Owned workspace binding ID")
|
|
96
|
+
.requiredOption("--plan <file>", "Saved JSON proposal returned by prepare")
|
|
97
|
+
.option("--yes", "Apply this exact proposal; preview is the default")
|
|
98
|
+
.option("--actor <name>", "Who authorized the restore")
|
|
99
|
+
.option("--reason <text>", "Why this turn is being restored")
|
|
100
|
+
.action(
|
|
101
|
+
run((root, options) =>
|
|
102
|
+
applyTurnRollback(root, {
|
|
103
|
+
binding: binding(root, options),
|
|
104
|
+
plan: readJsonRecord<TurnRollbackPlan>(options.plan!, "rollback proposal"),
|
|
105
|
+
yes: options.yes,
|
|
106
|
+
actor: options.actor,
|
|
107
|
+
reason: options.reason,
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
}
|
package/src/commands/workflow.ts
CHANGED
|
@@ -73,6 +73,15 @@ interface WorkflowConfirmedMutationOpts {
|
|
|
73
73
|
|
|
74
74
|
interface WorkflowReclaimOpts extends WorkflowConfirmedMutationOpts {
|
|
75
75
|
discard?: boolean;
|
|
76
|
+
unknownLiveness?: string;
|
|
77
|
+
actor?: string;
|
|
78
|
+
reason?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface WorkflowUnlockOpts extends WorkflowConfirmedMutationOpts {
|
|
82
|
+
lockSha256?: string;
|
|
83
|
+
actor?: string;
|
|
84
|
+
reason?: string;
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
export function registerWorkflowCommand(program: Command, emit: EmitContext): void {
|
|
@@ -622,6 +631,12 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
|
|
|
622
631
|
)
|
|
623
632
|
.option("--yes", "Confirm that this commits or discards uncommitted work, then releases")
|
|
624
633
|
.option("--discard", "Throw the uncommitted work away instead of salvaging it")
|
|
634
|
+
.option(
|
|
635
|
+
"--unknown-liveness <sha256>",
|
|
636
|
+
"Recover unknown evidence pinned by the preview digest; active evidence still refuses",
|
|
637
|
+
)
|
|
638
|
+
.option("--actor <id>", "Actor responsible for an audited recovery")
|
|
639
|
+
.option("--reason <text>", "Reason for an audited recovery")
|
|
625
640
|
.option("--json", "Emit the reclaim preview or confirmed result as JSON")
|
|
626
641
|
.action(async (runId: string, opts: WorkflowReclaimOpts) => {
|
|
627
642
|
const coordRoot = resolveCoordRoot();
|
|
@@ -648,6 +663,13 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
|
|
|
648
663
|
const result = await provider.reclaim(binding, {
|
|
649
664
|
mode: opts.discard ? "discard" : "salvage",
|
|
650
665
|
yes: opts.yes,
|
|
666
|
+
unknownLiveness: opts.unknownLiveness
|
|
667
|
+
? {
|
|
668
|
+
evidenceSha256: opts.unknownLiveness,
|
|
669
|
+
actor: opts.actor!,
|
|
670
|
+
reason: opts.reason!,
|
|
671
|
+
}
|
|
672
|
+
: undefined,
|
|
651
673
|
});
|
|
652
674
|
if (opts.json) {
|
|
653
675
|
emit.config({ format: "json" });
|
|
@@ -662,6 +684,7 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
|
|
|
662
684
|
emit.text(
|
|
663
685
|
`reclaim preview: ${result.run_id}\n` +
|
|
664
686
|
`mode: ${result.mode}\nliveness: ${result.liveness}\nownership: ${result.ownership}\n` +
|
|
687
|
+
`evidence sha256: ${result.evidence_sha256}\n` +
|
|
665
688
|
`lease holder: ${holders.length > 0 ? holders.join(", ") : "none observed"}\n` +
|
|
666
689
|
`dirty paths: ${result.dirty_paths === null ? "unknown" : result.dirty_paths.length > 0 ? result.dirty_paths.join(", ") : "none"}\n` +
|
|
667
690
|
(result.ownership_detail ? `ownership detail: ${result.ownership_detail}\n` : "") +
|
|
@@ -685,6 +708,42 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
|
|
|
685
708
|
}
|
|
686
709
|
});
|
|
687
710
|
|
|
711
|
+
workflow
|
|
712
|
+
.command("unlock <run-id>")
|
|
713
|
+
.description(
|
|
714
|
+
"Preview or explicitly retire one exact resume lock whose local owner is proven dead.",
|
|
715
|
+
)
|
|
716
|
+
.option("--lock-sha256 <sha256>", "Exact lock identity digest returned by the preview")
|
|
717
|
+
.option("--actor <id>", "Actor responsible for the recovery")
|
|
718
|
+
.option("--reason <text>", "Reason for the recovery")
|
|
719
|
+
.option("--yes", "Confirm removal after dead-owner and exact-lock checks")
|
|
720
|
+
.option("--json", "Emit the recovery preview or result as JSON")
|
|
721
|
+
.action(async (runId: string, opts: WorkflowUnlockOpts) => {
|
|
722
|
+
try {
|
|
723
|
+
const coordRoot = resolveCoordRoot();
|
|
724
|
+
if (!coordRoot) throw new Error("no .harnery/ coordination root found");
|
|
725
|
+
const { recoverWorkflowResumeLease } = await import("../core/workflow/index.ts");
|
|
726
|
+
const result = recoverWorkflowResumeLease(coordRoot, runId, opts);
|
|
727
|
+
if (opts.json) {
|
|
728
|
+
emit.config({ format: "json" });
|
|
729
|
+
emit.data(result);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
emit.text(
|
|
733
|
+
`resume lock ${result.action}: ${runId}\nowner: ${result.owner_state}\nlock sha256: ${result.lock_sha256 ?? "absent"}\n` +
|
|
734
|
+
(result.action === "preview"
|
|
735
|
+
? "Pass --lock-sha256, --actor, --reason and --yes to confirm exact dead-owner recovery.\n"
|
|
736
|
+
: `recovery: ${result.recovery_id}\n`),
|
|
737
|
+
);
|
|
738
|
+
} catch (error) {
|
|
739
|
+
emit.error({
|
|
740
|
+
code: "workflow_resume_unlock_failed",
|
|
741
|
+
message: error instanceof Error ? error.message : String(error),
|
|
742
|
+
});
|
|
743
|
+
process.exit(1);
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
|
|
688
747
|
const approvals = program
|
|
689
748
|
.command("approval")
|
|
690
749
|
.description("Inspect and resolve durable run-policy approvals.");
|
|
@@ -265,7 +265,27 @@ export function parsePidmapRowStartToken(row: string): string | undefined {
|
|
|
265
265
|
* a test can hold this one against `processStartToken` and fail on drift.
|
|
266
266
|
*/
|
|
267
267
|
export function pidStartToken(pid: number): string | null {
|
|
268
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
268
269
|
const forced = process.env.HARNERY_PID_PROBE;
|
|
270
|
+
if (process.platform === "win32" && forced !== "procfs" && forced !== "ps") {
|
|
271
|
+
try {
|
|
272
|
+
const out = spawnSync(
|
|
273
|
+
"powershell.exe",
|
|
274
|
+
[
|
|
275
|
+
"-NoLogo",
|
|
276
|
+
"-NoProfile",
|
|
277
|
+
"-NonInteractive",
|
|
278
|
+
"-Command",
|
|
279
|
+
`$process = [Diagnostics.Process]::GetProcessById(${pid}); try { 'w' + $process.StartTime.ToFileTimeUtc().ToString([Globalization.CultureInfo]::InvariantCulture) } finally { $process.Dispose() }`,
|
|
280
|
+
],
|
|
281
|
+
{ encoding: "utf8", timeout: 10_000, windowsHide: true },
|
|
282
|
+
);
|
|
283
|
+
const token = String(out.stdout ?? "").trim();
|
|
284
|
+
return out.status === 0 && /^w\d+$/.test(token) ? token : null;
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
269
289
|
const useProcfs = forced === "procfs" || (forced !== "ps" && existsSync("/proc/self/stat"));
|
|
270
290
|
// One machine, one probe. Falling back to the other on a read failure would
|
|
271
291
|
// answer in the wrong dialect and read as a recycled pid.
|
|
@@ -34,9 +34,10 @@
|
|
|
34
34
|
import { spawnSync } from "node:child_process";
|
|
35
35
|
import { existsSync, readFileSync } from "node:fs";
|
|
36
36
|
import { coordEnv } from "../../../lib/env.ts";
|
|
37
|
+
import { signalProcessWithHandle, windowsProcessStartToken } from "./process-handle.ts";
|
|
37
38
|
|
|
38
39
|
/** Which probe answers "when did this process start?" on this machine. */
|
|
39
|
-
export type StartTokenProbe = "procfs" | "ps";
|
|
40
|
+
export type StartTokenProbe = "procfs" | "ps" | "windows";
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* The `ps` probe is text formatted by `strftime("%c", localtime(...))`, so its
|
|
@@ -60,6 +61,7 @@ let cachedBootId: string | null | undefined;
|
|
|
60
61
|
export function startTokenProbe(): StartTokenProbe {
|
|
61
62
|
const override = coordEnv("PID_PROBE");
|
|
62
63
|
if (override === "procfs" || override === "ps") return override;
|
|
64
|
+
if (process.platform === "win32") return "windows";
|
|
63
65
|
if (cachedProbe === undefined) {
|
|
64
66
|
cachedProbe = existsSync("/proc/self/stat") ? "procfs" : "ps";
|
|
65
67
|
}
|
|
@@ -157,7 +159,12 @@ function defaultPsRun(pid: number): { status: number | null; stdout: string } {
|
|
|
157
159
|
*/
|
|
158
160
|
export function processStartToken(pid: number): string | null {
|
|
159
161
|
if (!Number.isFinite(pid) || pid <= 0) return null;
|
|
160
|
-
|
|
162
|
+
const probe = startTokenProbe();
|
|
163
|
+
return probe === "windows"
|
|
164
|
+
? windowsProcessStartToken(pid)
|
|
165
|
+
: probe === "procfs"
|
|
166
|
+
? procfsStartToken(pid)
|
|
167
|
+
: psStartToken(pid);
|
|
161
168
|
}
|
|
162
169
|
|
|
163
170
|
export type ProcessGenerationVerdict = "same" | "different" | "unknown";
|
|
@@ -191,14 +198,12 @@ export function verifyProcessGeneration(
|
|
|
191
198
|
|
|
192
199
|
export interface ProcessGenerationSignalDependencies {
|
|
193
200
|
readToken?: ReadProcessStartToken;
|
|
194
|
-
|
|
201
|
+
signalProcess?: typeof signalProcessWithHandle;
|
|
195
202
|
}
|
|
196
203
|
|
|
197
204
|
/**
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* The process can still exit and its PID can be reused between the token read
|
|
201
|
-
* and the signal. Eliminating that race requires a generation-bound OS handle.
|
|
205
|
+
* Observations may refuse a signal, but only the retained native process handle
|
|
206
|
+
* can authorize it. No numeric-PID effect follows the preliminary token read.
|
|
202
207
|
*/
|
|
203
208
|
export function signalIfSameGeneration(
|
|
204
209
|
pid: number,
|
|
@@ -206,14 +211,12 @@ export function signalIfSameGeneration(
|
|
|
206
211
|
signal: NodeJS.Signals | number,
|
|
207
212
|
dependencies: ProcessGenerationSignalDependencies = {},
|
|
208
213
|
): { sent: boolean; verdict: ProcessGenerationVerdict } {
|
|
209
|
-
const kill = dependencies.kill ?? process.kill;
|
|
210
214
|
const verdict = verifyProcessGeneration(pid, recordedToken, dependencies.readToken);
|
|
211
215
|
if (verdict !== "same") return { sent: false, verdict };
|
|
212
216
|
try {
|
|
213
|
-
|
|
214
|
-
return { sent: true, verdict };
|
|
217
|
+
return (dependencies.signalProcess ?? signalProcessWithHandle)(pid, recordedToken!, signal);
|
|
215
218
|
} catch {
|
|
216
|
-
return { sent: false, verdict };
|
|
219
|
+
return { sent: false, verdict: "unknown" };
|
|
217
220
|
}
|
|
218
221
|
}
|
|
219
222
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { constants } from "node:os";
|
|
3
|
+
import type { ProcessGenerationVerdict } from "./proc-start.ts";
|
|
4
|
+
|
|
5
|
+
export interface ProcessHandleSignalResult {
|
|
6
|
+
sent: boolean;
|
|
7
|
+
verdict: ProcessGenerationVerdict;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface NativeHelperResult extends ProcessHandleSignalResult {
|
|
11
|
+
token?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type NativeProcessHelperRunner = (
|
|
15
|
+
command: string,
|
|
16
|
+
args: string[],
|
|
17
|
+
options: {
|
|
18
|
+
input: string;
|
|
19
|
+
encoding: "utf8";
|
|
20
|
+
timeout: number;
|
|
21
|
+
maxBuffer: number;
|
|
22
|
+
windowsHide: boolean;
|
|
23
|
+
},
|
|
24
|
+
) => { status: number | null; stdout: string | Buffer | null; error?: Error };
|
|
25
|
+
|
|
26
|
+
// The helper owns the descriptor throughout observation and effect. The second
|
|
27
|
+
// input line authorizes the effect; EOF releases the handle without signaling.
|
|
28
|
+
export const LINUX_PROCESS_HANDLE_SCRIPT = `
|
|
29
|
+
import json, os, pathlib, signal, sys
|
|
30
|
+
result = {"sent": False, "verdict": "unknown"}
|
|
31
|
+
fd = None
|
|
32
|
+
try:
|
|
33
|
+
request = json.loads(sys.stdin.readline())
|
|
34
|
+
if not hasattr(os, "pidfd_open") or not hasattr(signal, "pidfd_send_signal"):
|
|
35
|
+
raise RuntimeError("pidfd support unavailable")
|
|
36
|
+
fd = os.pidfd_open(request["pid"])
|
|
37
|
+
stat = pathlib.Path("/proc/%d/stat" % request["pid"]).read_text()
|
|
38
|
+
ticks = stat[stat.rfind(") ") + 2:].split()[19]
|
|
39
|
+
boot = pathlib.Path("/proc/sys/kernel/random/boot_id").read_text().strip().replace("-", "")[:8]
|
|
40
|
+
token = "l%s.%s" % (boot, ticks)
|
|
41
|
+
result["token"] = token
|
|
42
|
+
recorded = request.get("token")
|
|
43
|
+
result["verdict"] = "same" if token == recorded else "different"
|
|
44
|
+
if request["mode"] == "signal" and result["verdict"] == "same":
|
|
45
|
+
print(json.dumps({"phase": "verified"}), flush=True)
|
|
46
|
+
if sys.stdin.readline().strip() == "signal":
|
|
47
|
+
signal.pidfd_send_signal(fd, request["signal"])
|
|
48
|
+
result["sent"] = True
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
finally:
|
|
52
|
+
if fd is not None:
|
|
53
|
+
os.close(fd)
|
|
54
|
+
print(json.dumps(result), flush=True)
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
export const WINDOWS_PROCESS_HANDLE_SCRIPT = `
|
|
58
|
+
$ErrorActionPreference = 'Stop'
|
|
59
|
+
$result = @{ sent = $false; verdict = 'unknown' }
|
|
60
|
+
$handle = [IntPtr]::Zero
|
|
61
|
+
try {
|
|
62
|
+
Add-Type -TypeDefinition @'
|
|
63
|
+
using System;
|
|
64
|
+
using System.Runtime.InteropServices;
|
|
65
|
+
public static class HarneryProcessHandle {
|
|
66
|
+
[DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr OpenProcess(uint access, bool inherit, uint processId);
|
|
67
|
+
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool GetProcessTimes(IntPtr handle, out long creation, out long exit, out long kernel, out long user);
|
|
68
|
+
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool TerminateProcess(IntPtr handle, uint exitCode);
|
|
69
|
+
[DllImport("kernel32.dll")] public static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
|
|
70
|
+
[DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr handle);
|
|
71
|
+
}
|
|
72
|
+
'@
|
|
73
|
+
$request = [Console]::In.ReadLine() | ConvertFrom-Json
|
|
74
|
+
$access = [uint32]0x101000
|
|
75
|
+
if ($request.mode -eq 'signal') { $access = $access -bor 1 }
|
|
76
|
+
$handle = [HarneryProcessHandle]::OpenProcess($access, $false, [uint32]$request.pid)
|
|
77
|
+
if ($handle -eq [IntPtr]::Zero) { throw 'process handle unavailable' }
|
|
78
|
+
[long]$creation = 0; [long]$exitTime = 0; [long]$kernel = 0; [long]$userTime = 0
|
|
79
|
+
if (-not [HarneryProcessHandle]::GetProcessTimes($handle, [ref]$creation, [ref]$exitTime, [ref]$kernel, [ref]$userTime)) { throw 'process time unavailable' }
|
|
80
|
+
$token = 'w' + $creation.ToString([Globalization.CultureInfo]::InvariantCulture)
|
|
81
|
+
$result.token = $token
|
|
82
|
+
$result.verdict = if ($token -ceq $request.token) { 'same' } else { 'different' }
|
|
83
|
+
if ($request.mode -eq 'signal' -and $result.verdict -eq 'same') {
|
|
84
|
+
[Console]::Out.WriteLine('{"phase":"verified"}')
|
|
85
|
+
[Console]::Out.Flush()
|
|
86
|
+
if ([Console]::In.ReadLine() -eq 'signal' -and [HarneryProcessHandle]::WaitForSingleObject($handle, 0) -eq 258) {
|
|
87
|
+
$result.sent = [HarneryProcessHandle]::TerminateProcess($handle, 1)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {} finally {
|
|
91
|
+
if ($handle -ne [IntPtr]::Zero) { [void][HarneryProcessHandle]::CloseHandle($handle) }
|
|
92
|
+
}
|
|
93
|
+
$result | ConvertTo-Json -Compress
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
function runHelper(
|
|
97
|
+
request: { mode: "probe" | "signal"; pid: number; token?: string; signal?: number },
|
|
98
|
+
platform: NodeJS.Platform,
|
|
99
|
+
run: NativeProcessHelperRunner,
|
|
100
|
+
): NativeHelperResult {
|
|
101
|
+
const unknown = { sent: false, verdict: "unknown" } as const;
|
|
102
|
+
if (!Number.isInteger(request.pid) || request.pid <= 0) return unknown;
|
|
103
|
+
if (platform !== "linux" && platform !== "win32") return unknown;
|
|
104
|
+
try {
|
|
105
|
+
const command = platform === "linux" ? "python3" : "powershell.exe";
|
|
106
|
+
const args =
|
|
107
|
+
platform === "linux"
|
|
108
|
+
? ["-I", "-c", LINUX_PROCESS_HANDLE_SCRIPT]
|
|
109
|
+
: ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_PROCESS_HANDLE_SCRIPT];
|
|
110
|
+
const output = run(command, args, {
|
|
111
|
+
input: `${JSON.stringify(request)}\nsignal\n`,
|
|
112
|
+
encoding: "utf8",
|
|
113
|
+
timeout: 10_000,
|
|
114
|
+
maxBuffer: 64 * 1024,
|
|
115
|
+
windowsHide: true,
|
|
116
|
+
});
|
|
117
|
+
if (output.error || output.status !== 0) return unknown;
|
|
118
|
+
const lines = String(output.stdout ?? "")
|
|
119
|
+
.trim()
|
|
120
|
+
.split(/\r?\n/);
|
|
121
|
+
const parsed = JSON.parse(lines.at(-1) ?? "") as NativeHelperResult;
|
|
122
|
+
if (
|
|
123
|
+
typeof parsed.sent !== "boolean" ||
|
|
124
|
+
!["same", "different", "unknown"].includes(parsed.verdict) ||
|
|
125
|
+
(parsed.sent && parsed.verdict !== "same")
|
|
126
|
+
)
|
|
127
|
+
return unknown;
|
|
128
|
+
return parsed;
|
|
129
|
+
} catch {
|
|
130
|
+
return unknown;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Windows creation FILETIME, read from an OpenProcess handle. */
|
|
135
|
+
export function windowsProcessStartToken(
|
|
136
|
+
pid: number,
|
|
137
|
+
run: NativeProcessHelperRunner = spawnSync,
|
|
138
|
+
): string | null {
|
|
139
|
+
const result = runHelper({ mode: "probe", pid }, "win32", run);
|
|
140
|
+
return typeof result.token === "string" && /^w\d+$/.test(result.token) ? result.token : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** No numeric-PID signaling fallback is permitted when an exact handle fails. */
|
|
144
|
+
export function signalProcessWithHandle(
|
|
145
|
+
pid: number,
|
|
146
|
+
recordedToken: string,
|
|
147
|
+
signal: NodeJS.Signals | number,
|
|
148
|
+
options: { platform?: NodeJS.Platform; run?: NativeProcessHelperRunner } = {},
|
|
149
|
+
): ProcessHandleSignalResult {
|
|
150
|
+
const platform = options.platform ?? process.platform;
|
|
151
|
+
const number = typeof signal === "number" ? signal : constants.signals[signal];
|
|
152
|
+
if (
|
|
153
|
+
!Number.isInteger(number) ||
|
|
154
|
+
number <= 0 ||
|
|
155
|
+
(platform === "linux" && !/^l[0-9a-f]{8}\.\d+$/.test(recordedToken)) ||
|
|
156
|
+
(platform === "win32" && (!/^w\d+$/.test(recordedToken) || ![9, 15].includes(number)))
|
|
157
|
+
)
|
|
158
|
+
return { sent: false, verdict: "unknown" };
|
|
159
|
+
const result = runHelper(
|
|
160
|
+
{ mode: "signal", pid, token: recordedToken, signal: number },
|
|
161
|
+
platform,
|
|
162
|
+
options.run ?? spawnSync,
|
|
163
|
+
);
|
|
164
|
+
return { sent: result.sent, verdict: result.verdict };
|
|
165
|
+
}
|
|
@@ -14,6 +14,7 @@ import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
|
14
14
|
import { encodeLinkSafeComponent } from "../../lib/local-file-url.ts";
|
|
15
15
|
import { findLiveTunnelForOrigin, tunnelServesPaths } from "../../lib/tunnel/state.ts";
|
|
16
16
|
import { resolveWebPort } from "../config.ts";
|
|
17
|
+
import { createTunnelReadinessVerifier } from "../servers/index.ts";
|
|
17
18
|
import { stateFileMode } from "../storage/modes.ts";
|
|
18
19
|
import { ARTIFACT_MANIFEST } from "./constants.ts";
|
|
19
20
|
import { resolveArtifactRef, showArtifact } from "./index.ts";
|
|
@@ -330,7 +331,12 @@ function deliveryLinkBase(repoRoot: string, environment: DisplayEnvironment): st
|
|
|
330
331
|
// route: its gate would refuse every link on the card.
|
|
331
332
|
const tunnel =
|
|
332
333
|
environment.tunnelUrl === undefined
|
|
333
|
-
? findLiveTunnelForOrigin(
|
|
334
|
+
? findLiveTunnelForOrigin(
|
|
335
|
+
webPort,
|
|
336
|
+
`localhost:${webPort}`,
|
|
337
|
+
repoRoot,
|
|
338
|
+
createTunnelReadinessVerifier({ coordRoot: repoRoot }),
|
|
339
|
+
)
|
|
334
340
|
: null;
|
|
335
341
|
const tunnelUrl =
|
|
336
342
|
environment.tunnelUrl === undefined
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { randomUUID } from "node:crypto";
|
|
3
2
|
import {
|
|
4
3
|
closeSync,
|
|
@@ -10,9 +9,10 @@ import {
|
|
|
10
9
|
unlinkSync,
|
|
11
10
|
writeFileSync,
|
|
12
11
|
} from "node:fs";
|
|
13
|
-
import { hostname
|
|
12
|
+
import { hostname } from "node:os";
|
|
14
13
|
import { resolve } from "node:path";
|
|
15
14
|
import {
|
|
15
|
+
type ProcessGenerationSignalDependencies,
|
|
16
16
|
type ProcessGenerationVerdict,
|
|
17
17
|
processStartToken,
|
|
18
18
|
signalIfSameGeneration,
|
|
@@ -55,80 +55,6 @@ export interface RunResourceServiceInput {
|
|
|
55
55
|
wait?: (milliseconds: number) => Promise<void>;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
export type EnsureResourceServiceResult =
|
|
59
|
-
| { state: "running" | "started"; status: ResourceServiceStatus }
|
|
60
|
-
| { state: "unsupported" | "unavailable"; status: ResourceServiceStatus; error: string };
|
|
61
|
-
|
|
62
|
-
export async function spawnResourceService(
|
|
63
|
-
coordRootRaw: string,
|
|
64
|
-
options: { intervalMs?: number } = {},
|
|
65
|
-
): Promise<ResourceServiceStatus> {
|
|
66
|
-
const coordRoot = resolve(coordRootRaw);
|
|
67
|
-
const current = readResourceServiceStatus(coordRoot);
|
|
68
|
-
if (current.liveness === "unknown") throw unknownResourceOwnerError();
|
|
69
|
-
if (current.liveness === "running") {
|
|
70
|
-
throw new Error(`resource observer is already running under pid ${current.record?.pid}`);
|
|
71
|
-
}
|
|
72
|
-
const intervalMs = normalizeInterval(options.intervalMs);
|
|
73
|
-
const paths = resourcePaths(coordRoot);
|
|
74
|
-
assertResourceLeaseAvailable(paths.lease);
|
|
75
|
-
mkdirSync(paths.root, { recursive: true, mode: stateDirMode() });
|
|
76
|
-
const harnBin = new URL("../../../bin/harn", import.meta.url).pathname;
|
|
77
|
-
if (!existsSync(harnBin)) throw new Error(`cannot find harn executable at ${harnBin}`);
|
|
78
|
-
let spawnError: Error | undefined;
|
|
79
|
-
const child = spawn(
|
|
80
|
-
harnBin,
|
|
81
|
-
["resources", "service", "daemon", "--root", coordRoot, "--interval-ms", String(intervalMs)],
|
|
82
|
-
{
|
|
83
|
-
cwd: coordRoot,
|
|
84
|
-
detached: true,
|
|
85
|
-
stdio: ["ignore", "ignore", "ignore"],
|
|
86
|
-
env: {
|
|
87
|
-
...process.env,
|
|
88
|
-
HARNERY_COORD_ROOT_OVERRIDE: coordRoot,
|
|
89
|
-
HARNERY_OUTPUT_SESSION_TEE: "0",
|
|
90
|
-
},
|
|
91
|
-
},
|
|
92
|
-
);
|
|
93
|
-
child.once("error", (error) => {
|
|
94
|
-
spawnError = error;
|
|
95
|
-
});
|
|
96
|
-
child.unref();
|
|
97
|
-
const deadline = Date.now() + 5_000;
|
|
98
|
-
while (Date.now() < deadline) {
|
|
99
|
-
await delay(50);
|
|
100
|
-
if (spawnError) throw spawnError;
|
|
101
|
-
const status = readResourceServiceStatus(coordRoot);
|
|
102
|
-
if (status.running && status.record?.pid === child.pid) return status;
|
|
103
|
-
if (child.exitCode !== null) break;
|
|
104
|
-
}
|
|
105
|
-
throw new Error(`resource observer failed to start; inspect ${paths.service}`);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export async function ensureResourceServiceRunning(
|
|
109
|
-
coordRoot: string,
|
|
110
|
-
): Promise<EnsureResourceServiceResult> {
|
|
111
|
-
const status = readResourceServiceStatus(coordRoot);
|
|
112
|
-
if (status.running) return { state: "running", status };
|
|
113
|
-
if (platform() !== "linux") {
|
|
114
|
-
return {
|
|
115
|
-
state: "unsupported",
|
|
116
|
-
status,
|
|
117
|
-
error: `resource observer does not yet support ${platform()}`,
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
try {
|
|
121
|
-
const started = await spawnResourceService(coordRoot);
|
|
122
|
-
return { state: "started", status: started };
|
|
123
|
-
} catch (error) {
|
|
124
|
-
return {
|
|
125
|
-
state: "unavailable",
|
|
126
|
-
status: readResourceServiceStatus(coordRoot),
|
|
127
|
-
error: error instanceof Error ? error.message : String(error),
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
58
|
export interface ResourceServiceStopResult extends ResourceServiceStatus {
|
|
133
59
|
signal_sent: boolean;
|
|
134
60
|
signal_verdict: ProcessGenerationVerdict;
|
|
@@ -136,9 +62,7 @@ export interface ResourceServiceStopResult extends ResourceServiceStatus {
|
|
|
136
62
|
|
|
137
63
|
export function requestResourceServiceStop(
|
|
138
64
|
coordRootRaw: string,
|
|
139
|
-
dependencies: ResourceProcessDependencies & {
|
|
140
|
-
kill?: (pid: number, signal: NodeJS.Signals | number) => unknown;
|
|
141
|
-
} = {},
|
|
65
|
+
dependencies: ResourceProcessDependencies & ProcessGenerationSignalDependencies = {},
|
|
142
66
|
): ResourceServiceStopResult {
|
|
143
67
|
const coordRoot = resolve(coordRootRaw);
|
|
144
68
|
const paths = resourcePaths(coordRoot);
|
|
@@ -329,15 +253,6 @@ function leaseIsLive(lease: ResourceServiceLease): ResourceServiceStatus["livene
|
|
|
329
253
|
return resourceProcessLiveness(lease).liveness;
|
|
330
254
|
}
|
|
331
255
|
|
|
332
|
-
function assertResourceLeaseAvailable(path: string): void {
|
|
333
|
-
if (!existsSync(path)) return;
|
|
334
|
-
const lease = readLease(path);
|
|
335
|
-
const liveness = lease ? leaseIsLive(lease) : "unknown";
|
|
336
|
-
if (liveness === "unknown") throw unknownResourceOwnerError();
|
|
337
|
-
if (liveness === "running")
|
|
338
|
-
throw new Error(`resource observer lease is held by pid ${lease?.pid}`);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
256
|
function unknownResourceOwnerError(): Error {
|
|
342
257
|
return new Error(
|
|
343
258
|
`resource observer process identity is unknown; run ${resolveBinName()} supervisor stop to write the durable stop request before starting again`,
|
|
@@ -225,19 +225,13 @@ export async function ensureSemanticServiceRunning(
|
|
|
225
225
|
|
|
226
226
|
export function requestSemanticServiceStop(coordRootRaw: string): SemanticServiceStatus {
|
|
227
227
|
const coordRoot = resolve(coordRootRaw);
|
|
228
|
-
const status = readSemanticServiceStatus(coordRoot);
|
|
229
228
|
mkdirSync(semanticPaths(coordRoot).root, { recursive: true, mode: stateDirMode() });
|
|
230
229
|
writePrivateJsonAtomic(semanticPaths(coordRoot).stop, {
|
|
231
230
|
requested_at: new Date().toISOString(),
|
|
232
231
|
requested_by_pid: process.pid,
|
|
233
232
|
});
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
process.kill(status.record.pid, "SIGTERM");
|
|
237
|
-
} catch {
|
|
238
|
-
// The durable stop request remains for a racing or restarted daemon.
|
|
239
|
-
}
|
|
240
|
-
}
|
|
233
|
+
// This record has no generation witness. The durable request is the stop
|
|
234
|
+
// authority; a numeric PID cannot authorize an additional signal.
|
|
241
235
|
return readSemanticServiceStatus(coordRoot);
|
|
242
236
|
}
|
|
243
237
|
|