harnery 0.46.1 → 0.47.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/README.md +24 -0
- package/bin/README.md +10 -0
- package/bin/agent-coord.mjs +3 -0
- package/bin/agent-hook.mjs +3 -0
- package/bin/harn.mjs +3 -0
- package/bin/run.mjs +43 -0
- package/dist/commander.d.ts +18 -6
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +74 -2
- package/dist/commands/agents.d.ts +25 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +70 -27
- package/dist/commands/autoupdate.d.ts +12 -0
- package/dist/commands/autoupdate.d.ts.map +1 -0
- package/dist/commands/autoupdate.js +190 -0
- package/dist/commands/computer.d.ts +45 -0
- package/dist/commands/computer.d.ts.map +1 -0
- package/dist/commands/computer.js +509 -0
- package/dist/commands/docs.js +3 -1
- package/dist/commands/fetch.js +11 -1
- package/dist/commands/grep.d.ts +72 -0
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +200 -47
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +15 -9
- package/dist/commands/rm.js +2 -2
- package/dist/commands/web.d.ts.map +1 -1
- package/dist/commands/web.js +2 -0
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +2 -3
- package/dist/core/agents/coord-bin.d.ts +3 -0
- package/dist/core/agents/coord-bin.d.ts.map +1 -1
- package/dist/core/agents/coord-bin.js +6 -0
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +6 -2
- package/dist/core/agents/render/prompt-context.d.ts +55 -7
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +122 -48
- package/dist/core/agents/state/proc-start.d.ts +3 -1
- package/dist/core/agents/state/proc-start.d.ts.map +1 -1
- package/dist/core/agents/state/proc-start.js +26 -4
- package/dist/core/autoupdate/engine.d.ts +97 -0
- package/dist/core/autoupdate/engine.d.ts.map +1 -0
- package/dist/core/autoupdate/engine.js +548 -0
- package/dist/core/autoupdate/fingerprint.d.ts +16 -0
- package/dist/core/autoupdate/fingerprint.d.ts.map +1 -0
- package/dist/core/autoupdate/fingerprint.js +73 -0
- package/dist/core/autoupdate/git.d.ts +30 -0
- package/dist/core/autoupdate/git.d.ts.map +1 -0
- package/dist/core/autoupdate/git.js +82 -0
- package/dist/core/autoupdate/index.d.ts +6 -0
- package/dist/core/autoupdate/index.d.ts.map +1 -0
- package/dist/core/autoupdate/index.js +4 -0
- package/dist/core/autoupdate/state.d.ts +59 -0
- package/dist/core/autoupdate/state.d.ts.map +1 -0
- package/dist/core/autoupdate/state.js +214 -0
- package/dist/core/autoupdate/types.d.ts +113 -0
- package/dist/core/autoupdate/types.d.ts.map +1 -0
- package/dist/core/autoupdate/types.js +35 -0
- package/dist/core/config.d.ts +53 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +114 -1
- package/dist/core/events/v3/bootstrap.js +1 -0
- package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
- package/dist/core/events/v3/runtime-build.js +1 -0
- package/dist/core/hooks/adapter/wiring.d.ts +12 -2
- package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
- package/dist/core/hooks/adapter/wiring.js +38 -8
- package/dist/core/hooks/autoupdate-cue.d.ts +29 -0
- package/dist/core/hooks/autoupdate-cue.d.ts.map +1 -0
- package/dist/core/hooks/autoupdate-cue.js +53 -0
- package/dist/core/hooks/cli.js +28 -3
- package/dist/core/hooks/effects/index.js +4 -4
- package/dist/core/hooks/resolve/coord-root.js +1 -0
- package/dist/core/semantic/contract.d.ts +3 -3
- package/dist/core/semantic/service.d.ts.map +1 -1
- package/dist/core/semantic/service.js +4 -2
- package/dist/core/storage/modes.d.ts +1 -1
- package/dist/core/storage/modes.d.ts.map +1 -1
- package/dist/core/storage/modes.js +5 -1
- package/dist/core/workflow/durable-record.d.ts +2 -0
- package/dist/core/workflow/durable-record.d.ts.map +1 -1
- package/dist/core/workflow/durable-record.js +13 -1
- package/dist/core/workflow/engine.js +4 -0
- package/dist/core/workflow/spawn-claude.d.ts +7 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +16 -4
- package/dist/core/workflow/specialists.d.ts.map +1 -1
- package/dist/core/workflow/specialists.js +1 -0
- package/dist/core/workflow/types.d.ts +10 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/leases.js +1 -9
- package/dist/lib/computer/cua.d.ts +50 -0
- package/dist/lib/computer/cua.d.ts.map +1 -0
- package/dist/lib/computer/cua.js +474 -0
- package/dist/lib/computer/host.d.ts +19 -0
- package/dist/lib/computer/host.d.ts.map +1 -0
- package/dist/lib/computer/host.js +72 -0
- package/dist/lib/computer/lease.d.ts +13 -0
- package/dist/lib/computer/lease.d.ts.map +1 -0
- package/dist/lib/computer/lease.js +269 -0
- package/dist/lib/computer/types.d.ts +163 -0
- package/dist/lib/computer/types.d.ts.map +1 -0
- package/dist/lib/computer/types.js +10 -0
- package/dist/lib/docs-metadata-v2.d.ts.map +1 -1
- package/dist/lib/docs-metadata-v2.js +6 -5
- package/dist/lib/exec.d.ts.map +1 -1
- package/dist/lib/exec.js +1 -0
- package/dist/lib/guarded-remove.d.ts.map +1 -1
- package/dist/lib/guarded-remove.js +5 -4
- package/dist/lib/headless/index.d.ts +33 -0
- package/dist/lib/headless/index.d.ts.map +1 -1
- package/dist/lib/headless/index.js +38 -2
- package/dist/lib/http/client.d.ts +2 -9
- package/dist/lib/http/client.d.ts.map +1 -1
- package/dist/lib/http/client.js +30 -9
- package/dist/lib/instructions/apply.d.ts +9 -0
- package/dist/lib/instructions/apply.d.ts.map +1 -1
- package/dist/lib/instructions/apply.js +92 -14
- package/dist/lib/instructions/git-hooks.d.ts.map +1 -1
- package/dist/lib/instructions/git-hooks.js +8 -4
- package/dist/lib/instructions/templates.d.ts +11 -0
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +81 -1
- package/package.json +5 -5
- package/schemas/config.schema.json +89 -1
- package/src/commander.ts +98 -8
- package/src/commands/agents.ts +100 -32
- package/src/commands/autoupdate.ts +250 -0
- package/src/commands/computer.ts +682 -0
- package/src/commands/docs.ts +3 -1
- package/src/commands/fetch.ts +11 -1
- package/src/commands/grep.ts +274 -59
- package/src/commands/init.ts +17 -7
- package/src/commands/rm.ts +2 -2
- package/src/commands/web.ts +2 -0
- package/src/core/agents/canonical-emit.ts +2 -3
- package/src/core/agents/coord-bin.ts +30 -0
- package/src/core/agents/coord-client.ts +6 -2
- package/src/core/agents/render/prompt-context.ts +152 -53
- package/src/core/agents/state/proc-start.ts +29 -5
- package/src/core/autoupdate/engine.ts +691 -0
- package/src/core/autoupdate/fingerprint.ts +89 -0
- package/src/core/autoupdate/git.ts +120 -0
- package/src/core/autoupdate/index.ts +29 -0
- package/src/core/autoupdate/state.ts +269 -0
- package/src/core/autoupdate/types.ts +135 -0
- package/src/core/config.ts +142 -1
- package/src/core/events/v3/bootstrap.ts +1 -0
- package/src/core/events/v3/runtime-build.ts +1 -0
- package/src/core/hooks/adapter/wiring.ts +51 -6
- package/src/core/hooks/autoupdate-cue.ts +72 -0
- package/src/core/hooks/cli.ts +27 -1
- package/src/core/hooks/effects/index.ts +4 -4
- package/src/core/hooks/resolve/coord-root.ts +1 -0
- package/src/core/semantic/service.ts +6 -2
- package/src/core/storage/modes.ts +7 -1
- package/src/core/workflow/durable-record.ts +15 -1
- package/src/core/workflow/engine.ts +3 -0
- package/src/core/workflow/spawn-claude.ts +16 -4
- package/src/core/workflow/specialists.ts +1 -0
- package/src/core/workflow/types.ts +10 -0
- package/src/core/workflow/workspaces/leases.ts +1 -9
- package/src/lib/computer/cua.ts +572 -0
- package/src/lib/computer/host.ts +87 -0
- package/src/lib/computer/lease.ts +299 -0
- package/src/lib/computer/types.ts +177 -0
- package/src/lib/docs-metadata-v2.ts +24 -5
- package/src/lib/exec.ts +1 -0
- package/src/lib/guarded-remove.ts +5 -3
- package/src/lib/headless/index.ts +66 -2
- package/src/lib/http/client.ts +33 -19
- package/src/lib/instructions/apply.ts +100 -12
- package/src/lib/instructions/git-hooks.ts +8 -4
- package/src/lib/instructions/templates.ts +95 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fingerprint of the watched paths outside Git. A change in any watched
|
|
3
|
+
* file's mtime or size, or in a watched directory's direct children, changes
|
|
4
|
+
* the fingerprint and forces an update run (`drift`).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { readdirSync, statSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Expand `~/` (or a bare `~`) and `$VAR` / `${VAR}`. Returns null when a
|
|
14
|
+
* referenced variable is unset, so the entry fingerprints as missing instead
|
|
15
|
+
* of resolving somewhere unintended.
|
|
16
|
+
*/
|
|
17
|
+
export function expandWatchPath(
|
|
18
|
+
entry: string,
|
|
19
|
+
projectRoot: string,
|
|
20
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
21
|
+
): string | null {
|
|
22
|
+
let unset = false;
|
|
23
|
+
let out = entry.replace(
|
|
24
|
+
/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
25
|
+
(_m, a, b) => {
|
|
26
|
+
const value = env[(a ?? b) as string];
|
|
27
|
+
if (value === undefined || value === "") {
|
|
28
|
+
unset = true;
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
if (unset) return null;
|
|
35
|
+
if (out === "~" || out.startsWith("~/") || out.startsWith("~\\")) {
|
|
36
|
+
const home = env.HOME || env.USERPROFILE || homedir();
|
|
37
|
+
out = join(home, out.slice(1));
|
|
38
|
+
}
|
|
39
|
+
return isAbsolute(out) ? resolve(out) : resolve(projectRoot, out);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function statLine(path: string): string | null {
|
|
43
|
+
try {
|
|
44
|
+
const st = statSync(path);
|
|
45
|
+
return `${st.isDirectory() ? "dir" : "file"}\t${Math.trunc(st.mtimeMs)}\t${st.isDirectory() ? 0 : st.size}`;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The raw lines behind the fingerprint (sorted), for diagnostics and tests. */
|
|
52
|
+
export function fingerprintLines(
|
|
53
|
+
watch: readonly string[],
|
|
54
|
+
projectRoot: string,
|
|
55
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
56
|
+
): string[] {
|
|
57
|
+
const lines: string[] = [];
|
|
58
|
+
for (const entry of watch) {
|
|
59
|
+
const path = expandWatchPath(entry, projectRoot, env);
|
|
60
|
+
const self = path ? statLine(path) : null;
|
|
61
|
+
if (!path || !self) {
|
|
62
|
+
lines.push(`${entry}\tmissing`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
lines.push(`${entry}\t${self}`);
|
|
66
|
+
if (!self.startsWith("dir\t")) continue;
|
|
67
|
+
let children: string[] = [];
|
|
68
|
+
try {
|
|
69
|
+
children = readdirSync(path);
|
|
70
|
+
} catch {
|
|
71
|
+
children = [];
|
|
72
|
+
}
|
|
73
|
+
for (const child of children) {
|
|
74
|
+
lines.push(`${entry}/${child}\t${statLine(join(path, child)) ?? "missing"}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return lines.sort();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** sha1 hex of the sorted fingerprint lines. */
|
|
81
|
+
export function fingerprint(
|
|
82
|
+
watch: readonly string[],
|
|
83
|
+
projectRoot: string,
|
|
84
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
85
|
+
): string {
|
|
86
|
+
return createHash("sha1")
|
|
87
|
+
.update(fingerprintLines(watch, projectRoot, env).join("\n"))
|
|
88
|
+
.digest("hex");
|
|
89
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The few Git reads self-update needs. Every call is synchronous, bounded by a
|
|
3
|
+
* timeout, and goes through an injectable runner so tests never touch a
|
|
4
|
+
* network remote.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
export interface GitResult {
|
|
10
|
+
status: number | null;
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type GitRunner = (args: string[], opts: { cwd: string; timeoutMs: number }) => GitResult;
|
|
16
|
+
|
|
17
|
+
const LOCAL_TIMEOUT_MS = 10_000;
|
|
18
|
+
const REMOTE_TIMEOUT_MS = 15_000;
|
|
19
|
+
|
|
20
|
+
export const defaultGitRunner: GitRunner = (args, opts) => {
|
|
21
|
+
const result = spawnSync("git", args, {
|
|
22
|
+
cwd: opts.cwd,
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
timeout: opts.timeoutMs,
|
|
25
|
+
windowsHide: true,
|
|
26
|
+
// Never block a background run on a credential prompt.
|
|
27
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
28
|
+
});
|
|
29
|
+
return {
|
|
30
|
+
status: result.error ? null : result.status,
|
|
31
|
+
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
|
32
|
+
stderr: typeof result.stderr === "string" ? result.stderr : "",
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function read(
|
|
37
|
+
run: GitRunner,
|
|
38
|
+
root: string,
|
|
39
|
+
args: string[],
|
|
40
|
+
timeoutMs = LOCAL_TIMEOUT_MS,
|
|
41
|
+
): string | null {
|
|
42
|
+
const result = run(args, { cwd: root, timeoutMs });
|
|
43
|
+
if (result.status !== 0) return null;
|
|
44
|
+
const out = result.stdout.trim();
|
|
45
|
+
return out || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function head(root: string, run: GitRunner = defaultGitRunner): string | null {
|
|
49
|
+
return read(run, root, ["rev-parse", "HEAD"]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Upstream {
|
|
53
|
+
remote: string;
|
|
54
|
+
branch: string;
|
|
55
|
+
/** e.g. `origin/main`. */
|
|
56
|
+
display: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The current branch's upstream, or null when detached or untracked. */
|
|
60
|
+
export function upstream(root: string, run: GitRunner = defaultGitRunner): Upstream | null {
|
|
61
|
+
const display = read(run, root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
62
|
+
if (!display) return null;
|
|
63
|
+
const current = read(run, root, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
64
|
+
if (current) {
|
|
65
|
+
const remote = read(run, root, ["config", "--get", `branch.${current}.remote`]);
|
|
66
|
+
const merge = read(run, root, ["config", "--get", `branch.${current}.merge`]);
|
|
67
|
+
if (remote && merge) {
|
|
68
|
+
return { remote, branch: merge.replace(/^refs\/heads\//, ""), display };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const slash = display.indexOf("/");
|
|
72
|
+
if (slash <= 0) return null;
|
|
73
|
+
return { remote: display.slice(0, slash), branch: display.slice(slash + 1), display };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The remote branch's sha, or null when the remote cannot be reached or lacks the branch. */
|
|
77
|
+
export function lsRemote(
|
|
78
|
+
root: string,
|
|
79
|
+
remote: string,
|
|
80
|
+
branch: string,
|
|
81
|
+
run: GitRunner = defaultGitRunner,
|
|
82
|
+
): string | null {
|
|
83
|
+
const out = read(
|
|
84
|
+
run,
|
|
85
|
+
root,
|
|
86
|
+
["ls-remote", "--exit-code", remote, `refs/heads/${branch}`],
|
|
87
|
+
REMOTE_TIMEOUT_MS,
|
|
88
|
+
);
|
|
89
|
+
if (!out) return null;
|
|
90
|
+
const sha = out.split(/\s+/)[0] ?? "";
|
|
91
|
+
return /^[0-9a-f]{40,64}$/.test(sha) ? sha : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** True when `sha` exists locally and is an ancestor of HEAD. */
|
|
95
|
+
export function contains(root: string, sha: string, run: GitRunner = defaultGitRunner): boolean {
|
|
96
|
+
const exists = run(["cat-file", "-e", `${sha}^{commit}`], {
|
|
97
|
+
cwd: root,
|
|
98
|
+
timeoutMs: LOCAL_TIMEOUT_MS,
|
|
99
|
+
});
|
|
100
|
+
if (exists.status !== 0) return false;
|
|
101
|
+
const ancestor = run(["merge-base", "--is-ancestor", sha, "HEAD"], {
|
|
102
|
+
cwd: root,
|
|
103
|
+
timeoutMs: LOCAL_TIMEOUT_MS,
|
|
104
|
+
});
|
|
105
|
+
return ancestor.status === 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function changedFiles(
|
|
109
|
+
root: string,
|
|
110
|
+
from: string,
|
|
111
|
+
to: string,
|
|
112
|
+
run: GitRunner = defaultGitRunner,
|
|
113
|
+
): string[] {
|
|
114
|
+
const out = read(run, root, ["diff", "--name-only", from, to]);
|
|
115
|
+
if (!out) return [];
|
|
116
|
+
return out
|
|
117
|
+
.split(/\r?\n/)
|
|
118
|
+
.map((line) => line.trim())
|
|
119
|
+
.filter(Boolean);
|
|
120
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export {
|
|
2
|
+
AUTOUPDATE_CHILD_ENV,
|
|
3
|
+
type AutoUpdateDeps,
|
|
4
|
+
type AutoUpdateStatus,
|
|
5
|
+
autoUpdateStatus,
|
|
6
|
+
autoUpdateTick,
|
|
7
|
+
checkForUpdate,
|
|
8
|
+
harnCliEntry,
|
|
9
|
+
isHookConfigPath,
|
|
10
|
+
isInstructionPath,
|
|
11
|
+
parseNoticeLines,
|
|
12
|
+
type RunAutoUpdateOptions,
|
|
13
|
+
type RunAutoUpdateResult,
|
|
14
|
+
runAutoUpdate,
|
|
15
|
+
type StepRunner,
|
|
16
|
+
type StepRunResult,
|
|
17
|
+
} from "./engine.ts";
|
|
18
|
+
export { expandWatchPath, fingerprint, fingerprintLines } from "./fingerprint.ts";
|
|
19
|
+
export type { GitResult, GitRunner, Upstream } from "./git.ts";
|
|
20
|
+
export {
|
|
21
|
+
addNotice,
|
|
22
|
+
autoUpdateLogPath,
|
|
23
|
+
autoUpdateStatePath,
|
|
24
|
+
type LockInspection,
|
|
25
|
+
type LockOwner,
|
|
26
|
+
pendingNotices,
|
|
27
|
+
readAutoUpdateState,
|
|
28
|
+
} from "./state.ts";
|
|
29
|
+
export * from "./types.ts";
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent state for project self-update: `state.json` (last run, converged
|
|
3
|
+
* point, notices, per-session delivery) and the `run.lock` directory that keeps
|
|
4
|
+
* one runner per checkout.
|
|
5
|
+
*
|
|
6
|
+
* Writers re-read the file immediately before writing (`mutateAutoUpdateState`)
|
|
7
|
+
* because a long run and the prompt hooks that deliver notices both update it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { hostname } from "node:os";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
AUTOUPDATE_FILES,
|
|
16
|
+
type AutoUpdateConfig,
|
|
17
|
+
type AutoUpdateNotice,
|
|
18
|
+
type AutoUpdateState,
|
|
19
|
+
type NoticeKind,
|
|
20
|
+
} from "./types.ts";
|
|
21
|
+
|
|
22
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
23
|
+
const NOTICE_DEDUPE_MS = DAY_MS;
|
|
24
|
+
const NOTICE_KEEP = 20;
|
|
25
|
+
const NEW_SESSION_WINDOW_MS = DAY_MS;
|
|
26
|
+
const DELIVERED_PRUNE_MS = 7 * DAY_MS;
|
|
27
|
+
|
|
28
|
+
export function autoUpdateDir(coordRoot: string): string {
|
|
29
|
+
return join(resolve(coordRoot), AUTOUPDATE_FILES.dir);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function autoUpdateStatePath(coordRoot: string): string {
|
|
33
|
+
return join(autoUpdateDir(coordRoot), AUTOUPDATE_FILES.state);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function autoUpdateStampPath(coordRoot: string): string {
|
|
37
|
+
return join(autoUpdateDir(coordRoot), AUTOUPDATE_FILES.stamp);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function autoUpdateLockPath(coordRoot: string): string {
|
|
41
|
+
return join(autoUpdateDir(coordRoot), AUTOUPDATE_FILES.lock);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The run log lives with the other Harnery logs, not in private state. */
|
|
45
|
+
export function autoUpdateLogPath(coordRoot: string): string {
|
|
46
|
+
return join(resolve(coordRoot), ".harnery", "logs", AUTOUPDATE_FILES.log);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function freshAutoUpdateState(): AutoUpdateState {
|
|
50
|
+
return { schema: 1, notices: [], delivered: {} };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
54
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isNotice(value: unknown): value is AutoUpdateNotice {
|
|
58
|
+
return (
|
|
59
|
+
isRecord(value) &&
|
|
60
|
+
typeof value.id === "string" &&
|
|
61
|
+
typeof value.at === "string" &&
|
|
62
|
+
typeof value.kind === "string" &&
|
|
63
|
+
typeof value.text === "string"
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Read state; a missing or corrupt file yields fresh state. */
|
|
68
|
+
export function readAutoUpdateState(coordRoot: string): AutoUpdateState {
|
|
69
|
+
let raw: string;
|
|
70
|
+
try {
|
|
71
|
+
raw = readFileSync(autoUpdateStatePath(coordRoot), "utf8");
|
|
72
|
+
} catch {
|
|
73
|
+
return freshAutoUpdateState();
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
77
|
+
if (!isRecord(parsed) || parsed.schema !== 1) return freshAutoUpdateState();
|
|
78
|
+
const state = parsed as unknown as AutoUpdateState;
|
|
79
|
+
const notices = Array.isArray(parsed.notices) ? parsed.notices.filter(isNotice) : [];
|
|
80
|
+
const delivered: Record<string, string> = {};
|
|
81
|
+
if (isRecord(parsed.delivered)) {
|
|
82
|
+
for (const [key, at] of Object.entries(parsed.delivered)) {
|
|
83
|
+
if (typeof at === "string") delivered[key] = at;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { ...state, schema: 1, notices, delivered };
|
|
87
|
+
} catch {
|
|
88
|
+
return freshAutoUpdateState();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Write state atomically (temp file + rename). */
|
|
93
|
+
export function writeAutoUpdateState(coordRoot: string, state: AutoUpdateState): void {
|
|
94
|
+
const dir = autoUpdateDir(coordRoot);
|
|
95
|
+
mkdirSync(dir, { recursive: true });
|
|
96
|
+
const target = autoUpdateStatePath(coordRoot);
|
|
97
|
+
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
98
|
+
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
|
|
99
|
+
try {
|
|
100
|
+
renameSync(tmp, target);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
rmSync(tmp, { force: true });
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Re-read, apply `mutate`, write. Returns the written state. */
|
|
108
|
+
export function mutateAutoUpdateState(
|
|
109
|
+
coordRoot: string,
|
|
110
|
+
mutate: (state: AutoUpdateState) => void,
|
|
111
|
+
): AutoUpdateState {
|
|
112
|
+
const state = readAutoUpdateState(coordRoot);
|
|
113
|
+
mutate(state);
|
|
114
|
+
writeAutoUpdateState(coordRoot, state);
|
|
115
|
+
return state;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function noticeId(kind: NoticeKind, text: string, at: string): string {
|
|
119
|
+
return createHash("sha1").update(`${kind}\n${text}\n${at}`).digest("hex").slice(0, 12);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Append a notice to `state` in place. The same kind and text within 24 hours
|
|
124
|
+
* is not repeated; only the newest 20 notices are kept. Returns the notice now
|
|
125
|
+
* on record (the earlier one when deduplicated).
|
|
126
|
+
*/
|
|
127
|
+
export function addNotice(
|
|
128
|
+
state: AutoUpdateState,
|
|
129
|
+
kind: NoticeKind,
|
|
130
|
+
text: string,
|
|
131
|
+
now: number = Date.now(),
|
|
132
|
+
): AutoUpdateNotice {
|
|
133
|
+
const existing = state.notices.find(
|
|
134
|
+
(n) => n.kind === kind && n.text === text && now - Date.parse(n.at) < NOTICE_DEDUPE_MS,
|
|
135
|
+
);
|
|
136
|
+
if (existing) return existing;
|
|
137
|
+
const at = new Date(now).toISOString();
|
|
138
|
+
const notice: AutoUpdateNotice = { id: noticeId(kind, text, at), at, kind, text };
|
|
139
|
+
state.notices.push(notice);
|
|
140
|
+
if (state.notices.length > NOTICE_KEEP) {
|
|
141
|
+
state.notices = state.notices.slice(state.notices.length - NOTICE_KEEP);
|
|
142
|
+
}
|
|
143
|
+
return notice;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Notices this session has not seen yet, marked delivered. A session key with
|
|
148
|
+
* no record sees only the last 24 hours. Cheap when there is nothing to say:
|
|
149
|
+
* no write happens unless a notice is returned.
|
|
150
|
+
*/
|
|
151
|
+
export function pendingNotices(
|
|
152
|
+
coordRoot: string,
|
|
153
|
+
sessionKey: string,
|
|
154
|
+
now: number = Date.now(),
|
|
155
|
+
): AutoUpdateNotice[] {
|
|
156
|
+
const state = readAutoUpdateState(coordRoot);
|
|
157
|
+
if (state.notices.length === 0) return [];
|
|
158
|
+
const since = state.delivered[sessionKey];
|
|
159
|
+
const cutoff = since ?? new Date(now - NEW_SESSION_WINDOW_MS).toISOString();
|
|
160
|
+
const fresh = state.notices.filter((n) => (since ? n.at > cutoff : n.at >= cutoff));
|
|
161
|
+
if (fresh.length === 0) return [];
|
|
162
|
+
const newest = fresh.reduce((max, n) => (n.at > max ? n.at : max), fresh[0]?.at ?? "");
|
|
163
|
+
mutateAutoUpdateState(coordRoot, (s) => {
|
|
164
|
+
const prior = s.delivered[sessionKey];
|
|
165
|
+
if (!prior || newest > prior) s.delivered[sessionKey] = newest;
|
|
166
|
+
for (const [key, at] of Object.entries(s.delivered)) {
|
|
167
|
+
const ms = Date.parse(at);
|
|
168
|
+
if (key !== sessionKey && (!Number.isFinite(ms) || now - ms > DELIVERED_PRUNE_MS)) {
|
|
169
|
+
delete s.delivered[key];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
return fresh;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------- run lock
|
|
177
|
+
|
|
178
|
+
export interface LockOwner {
|
|
179
|
+
pid: number;
|
|
180
|
+
at: string;
|
|
181
|
+
host?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Minutes after which a held lock counts as abandoned. */
|
|
185
|
+
export function lockStaleMinutes(
|
|
186
|
+
cfg: Pick<AutoUpdateConfig, "timeoutMinutes" | "steps" | "dailySteps">,
|
|
187
|
+
): number {
|
|
188
|
+
return cfg.timeoutMinutes * Math.max(1, cfg.steps.length + cfg.dailySteps.length) + 5;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface LockInspection {
|
|
192
|
+
held: boolean;
|
|
193
|
+
stale: boolean;
|
|
194
|
+
owner: LockOwner | null;
|
|
195
|
+
ageMs: number | null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Inspect the lock without taking it. One stat when free. */
|
|
199
|
+
export function inspectLock(
|
|
200
|
+
coordRoot: string,
|
|
201
|
+
cfg: Pick<AutoUpdateConfig, "timeoutMinutes" | "steps" | "dailySteps">,
|
|
202
|
+
now: number = Date.now(),
|
|
203
|
+
): LockInspection {
|
|
204
|
+
const lockDir = autoUpdateLockPath(coordRoot);
|
|
205
|
+
let dirMtime: number;
|
|
206
|
+
try {
|
|
207
|
+
dirMtime = statSync(lockDir).mtimeMs;
|
|
208
|
+
} catch {
|
|
209
|
+
return { held: false, stale: false, owner: null, ageMs: null };
|
|
210
|
+
}
|
|
211
|
+
let owner: LockOwner | null = null;
|
|
212
|
+
try {
|
|
213
|
+
const parsed = JSON.parse(readFileSync(join(lockDir, "owner.json"), "utf8")) as unknown;
|
|
214
|
+
if (isRecord(parsed) && typeof parsed.pid === "number" && typeof parsed.at === "string") {
|
|
215
|
+
owner = {
|
|
216
|
+
pid: parsed.pid,
|
|
217
|
+
at: parsed.at,
|
|
218
|
+
...(typeof parsed.host === "string" ? { host: parsed.host } : {}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
owner = null;
|
|
223
|
+
}
|
|
224
|
+
const since = owner ? Date.parse(owner.at) : dirMtime;
|
|
225
|
+
const ageMs = now - (Number.isFinite(since) ? since : dirMtime);
|
|
226
|
+
const stale = ageMs > lockStaleMinutes(cfg) * 60_000;
|
|
227
|
+
return { held: true, stale, owner, ageMs };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export type LockAttempt =
|
|
231
|
+
| { acquired: true; release: () => void }
|
|
232
|
+
| { acquired: false; owner: LockOwner | null };
|
|
233
|
+
|
|
234
|
+
/** Take the run lock, breaking it once when it is stale. */
|
|
235
|
+
export function acquireRunLock(
|
|
236
|
+
coordRoot: string,
|
|
237
|
+
cfg: Pick<AutoUpdateConfig, "timeoutMinutes" | "steps" | "dailySteps">,
|
|
238
|
+
now: number = Date.now(),
|
|
239
|
+
): LockAttempt {
|
|
240
|
+
mkdirSync(autoUpdateDir(coordRoot), { recursive: true });
|
|
241
|
+
const lockDir = autoUpdateLockPath(coordRoot);
|
|
242
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
243
|
+
try {
|
|
244
|
+
mkdirSync(lockDir);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
247
|
+
const inspection = inspectLock(coordRoot, cfg, now);
|
|
248
|
+
if (!inspection.stale || attempt > 0) return { acquired: false, owner: inspection.owner };
|
|
249
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const owner: LockOwner = {
|
|
253
|
+
pid: process.pid,
|
|
254
|
+
at: new Date(now).toISOString(),
|
|
255
|
+
host: hostname(),
|
|
256
|
+
};
|
|
257
|
+
writeFileSync(join(lockDir, "owner.json"), `${JSON.stringify(owner)}\n`);
|
|
258
|
+
let released = false;
|
|
259
|
+
return {
|
|
260
|
+
acquired: true,
|
|
261
|
+
release: () => {
|
|
262
|
+
if (released) return;
|
|
263
|
+
released = true;
|
|
264
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
return { acquired: false, owner: inspectLock(coordRoot, cfg, now).owner };
|
|
269
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared contracts for project self-update (ADR 0229).
|
|
3
|
+
*
|
|
4
|
+
* Activity fires it: every prompt, session start, and host CLI command calls
|
|
5
|
+
* `autoUpdateTick`, whose hot path is one stat of a throttle stamp. When the
|
|
6
|
+
* stamp is stale, the tick launches a detached `<harn> autoupdate run
|
|
7
|
+
* --background`, which exits early unless the upstream branch moved, a
|
|
8
|
+
* watched path outside Git changed, the daily check is due, or the last run
|
|
9
|
+
* failed. Only then does it run the host's configured steps.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** `.harnery/config.jsonc` `autoUpdate` section, after lenient parsing. */
|
|
13
|
+
export interface AutoUpdateConfig {
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
/** Minimum minutes between remote checks. Default 15, range 1..1440. */
|
|
16
|
+
checkMinutes: number;
|
|
17
|
+
/** Argv arrays run in order from the project root when an update is needed; first failure stops. */
|
|
18
|
+
steps: string[][];
|
|
19
|
+
/** Argv arrays run at most once per 24h even when current (e.g. a doctor). */
|
|
20
|
+
dailySteps: string[][];
|
|
21
|
+
/**
|
|
22
|
+
* Paths outside Git whose change forces a run (managed tools, generated
|
|
23
|
+
* hook configs). `~/` and `$VAR` expand; relative paths resolve from the
|
|
24
|
+
* project root. A directory is fingerprinted shallowly (its own mtime plus
|
|
25
|
+
* each direct child's mtime and size).
|
|
26
|
+
*/
|
|
27
|
+
watch: string[];
|
|
28
|
+
/** Per-step timeout in minutes. Default 20. */
|
|
29
|
+
timeoutMinutes: number;
|
|
30
|
+
/** Run `artifacts clean --yes` (expired only) at most once per day after a successful run. Default true. */
|
|
31
|
+
cleanup: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const AUTOUPDATE_DEFAULTS = {
|
|
35
|
+
checkMinutes: 15,
|
|
36
|
+
timeoutMinutes: 20,
|
|
37
|
+
/** After a failed run, wait at least this long before the next attempt. */
|
|
38
|
+
failureBackoffMinutes: 60,
|
|
39
|
+
dailyHours: 24,
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Step output protocol. A line `harnery-notice: <text>` (stdout or stderr)
|
|
44
|
+
* becomes a notice for the agents in this checkout. Exit code 75 means the
|
|
45
|
+
* step finished what it could but needs a person (outcome `blocked`), which
|
|
46
|
+
* is not retried as a failure.
|
|
47
|
+
*/
|
|
48
|
+
export const NOTICE_PREFIX = "harnery-notice:";
|
|
49
|
+
export const EXIT_NEEDS_PERSON = 75;
|
|
50
|
+
|
|
51
|
+
export type CheckReason =
|
|
52
|
+
| "disabled"
|
|
53
|
+
| "behind" // upstream has a commit HEAD does not contain
|
|
54
|
+
| "drift" // a watched path changed since the last converged run
|
|
55
|
+
| "daily" // daily steps are due
|
|
56
|
+
| "retry" // last run failed and the backoff elapsed
|
|
57
|
+
| "forced"
|
|
58
|
+
| "current" // nothing to do
|
|
59
|
+
| "no-upstream" // detached or untracked branch and no drift
|
|
60
|
+
| "offline"; // ls-remote failed
|
|
61
|
+
|
|
62
|
+
export interface CheckDecision {
|
|
63
|
+
needed: boolean;
|
|
64
|
+
reason: CheckReason;
|
|
65
|
+
head: string | null;
|
|
66
|
+
upstream: string | null; // e.g. "origin/main"
|
|
67
|
+
remoteSha: string | null;
|
|
68
|
+
fingerprint: string;
|
|
69
|
+
/** True when only daily steps should run (repo already current). */
|
|
70
|
+
dailyOnly: boolean;
|
|
71
|
+
detail?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type NoticeKind = "instructions" | "hooks" | "person" | "failure" | "info";
|
|
75
|
+
|
|
76
|
+
export interface AutoUpdateNotice {
|
|
77
|
+
id: string;
|
|
78
|
+
at: string;
|
|
79
|
+
kind: NoticeKind;
|
|
80
|
+
text: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface StepRecord {
|
|
84
|
+
argv: string[];
|
|
85
|
+
exitCode: number | null;
|
|
86
|
+
ms: number;
|
|
87
|
+
timedOut?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type RunOutcome =
|
|
91
|
+
| "current"
|
|
92
|
+
| "updated"
|
|
93
|
+
| "blocked"
|
|
94
|
+
| "failed"
|
|
95
|
+
| "skipped-locked"
|
|
96
|
+
| "offline";
|
|
97
|
+
|
|
98
|
+
export interface RunRecord {
|
|
99
|
+
startedAt: string;
|
|
100
|
+
finishedAt: string;
|
|
101
|
+
outcome: RunOutcome;
|
|
102
|
+
reason: CheckReason;
|
|
103
|
+
fromHead: string | null;
|
|
104
|
+
toHead: string | null;
|
|
105
|
+
steps: StepRecord[];
|
|
106
|
+
error?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `.harnery/private/autoupdate/state.json`. */
|
|
110
|
+
export interface AutoUpdateState {
|
|
111
|
+
schema: 1;
|
|
112
|
+
converged?: { head: string; remoteSha: string | null; fingerprint: string; at: string };
|
|
113
|
+
lastRun?: RunRecord;
|
|
114
|
+
dailyAt?: string;
|
|
115
|
+
cleanupAt?: string;
|
|
116
|
+
notices: AutoUpdateNotice[];
|
|
117
|
+
/** Per session key (agent instance id), the `at` of the newest notice already delivered. */
|
|
118
|
+
delivered: Record<string, string>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface TickResult {
|
|
122
|
+
action: "disabled" | "throttled" | "locked" | "launched" | "error";
|
|
123
|
+
detail?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Files under `.harnery/private/autoupdate/` (except `log`, see below). */
|
|
127
|
+
export const AUTOUPDATE_FILES = {
|
|
128
|
+
dir: ".harnery/private/autoupdate",
|
|
129
|
+
/** Throttle stamp; only its mtime matters. */
|
|
130
|
+
stamp: "checked-at",
|
|
131
|
+
state: "state.json",
|
|
132
|
+
lock: "run.lock",
|
|
133
|
+
/** Run log lives at .harnery/logs/autoupdate.log (not in this dir). */
|
|
134
|
+
log: "autoupdate.log",
|
|
135
|
+
} as const;
|