@veda-hq/dvara 0.0.0-stage → 0.5.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/LICENSE +21 -0
- package/README.md +121 -2
- package/dist/cli.js +83 -0
- package/dist/config.js +76 -0
- package/dist/context.js +67 -0
- package/dist/quiet.js +31 -0
- package/dist/records.js +96 -0
- package/dist/supervisor.js +371 -0
- package/package.json +49 -3
- package/shell/keep-going.sh +86 -0
- package/shell/stages.sh +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Veda Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,122 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @veda-hq/dvara
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
द्वार, gate. Git hooks for repositories coding agents work in: cheap checks at
|
|
4
|
+
commit, expensive ones once at push.
|
|
5
|
+
|
|
6
|
+
This release holds the runner every hook goes through. Your hook stays a bash
|
|
7
|
+
file in your repository; dvara runs it.
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
- **One history row per run.** `dvara run <hook-file> [args…]` runs the hook
|
|
12
|
+
under bash in its own process group and appends a row to
|
|
13
|
+
`<historyDir>/history.jsonl`: kind, who started it (git, a person, a
|
|
14
|
+
background preflight, a benchmark), exit code and status, each stage's start,
|
|
15
|
+
duration and input, the base and tree it was judged against, the commits a
|
|
16
|
+
push updated, and the hook's peak memory.
|
|
17
|
+
- **Signals reach the hook.** Ctrl-C, SIGTERM, SIGHUP and SIGQUIT are forwarded
|
|
18
|
+
to the whole group. A run that was signalled is reported as cancelled, with
|
|
19
|
+
the signal's exit code, even when the hook trapped it and exited 0, so an
|
|
20
|
+
interrupted gate never lets a push through.
|
|
21
|
+
- **Quiet on success.** A hook listed in `quietHooks` (default `pre-commit`)
|
|
22
|
+
prints its warnings and one line when it passes, and everything when it
|
|
23
|
+
fails. The full output is kept in `<historyDir>/<hook>.log`.
|
|
24
|
+
`DVARA_VERBOSE=1` streams it.
|
|
25
|
+
- **Keep-going mode.** For a preflight, a run of the push hook ahead of the
|
|
26
|
+
push: each stage's `exit 1` is recorded and the hook carries on, so one run
|
|
27
|
+
lists every failure.
|
|
28
|
+
- **A watchdog, if you have one.** `watchdog` names a module the supervisor
|
|
29
|
+
attaches to each run; it may stop a hung hook and decide the verdict. Without
|
|
30
|
+
one, or when it fails to load, the hook runs exactly as it would anyway.
|
|
31
|
+
|
|
32
|
+
Every one of these is fail-open: telemetry, quiet output and the watchdog never
|
|
33
|
+
change a hook's verdict.
|
|
34
|
+
|
|
35
|
+
## A hook under dvara
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
#!/usr/bin/env bash
|
|
39
|
+
# .husky/pre-push, or wherever core.hooksPath points
|
|
40
|
+
if [ "${DVARA_MEASURED_HOOK:-}" != "pre-push" ]; then
|
|
41
|
+
exec node node_modules/@veda-hq/dvara/dist/cli.js run "$0" "$@"
|
|
42
|
+
fi
|
|
43
|
+
source node_modules/@veda-hq/dvara/shell/stages.sh
|
|
44
|
+
if [ "${DVARA_MODE:-}" = preflight ] && [ $# -eq 0 ]; then
|
|
45
|
+
source node_modules/@veda-hq/dvara/shell/keep-going.sh
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
dvara_stage lint
|
|
49
|
+
pnpm lint || exit 1
|
|
50
|
+
dvara_passed && echo "✅ lint"
|
|
51
|
+
|
|
52
|
+
dvara_stage test
|
|
53
|
+
pnpm test || exit 1
|
|
54
|
+
|
|
55
|
+
if [ "${DVARA_MODE:-}" = preflight ] && [ $# -eq 0 ]; then dvara_keep_going_report; fi
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`stages.sh` gives the hook:
|
|
59
|
+
|
|
60
|
+
| Helper | Does |
|
|
61
|
+
| ------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
62
|
+
| `dvara_stage <name>` | marks the start of a stage |
|
|
63
|
+
| `dvara_record <type> <fields…>` | a fact for the row: `stage-input <stage> <kind> <digest>`, `proof <check> <outcome> <ms>`, `proof-reason …` |
|
|
64
|
+
| `dvara_record_refs "$stdin"` | the commits a push updates, from git's ref lines; never the ref names |
|
|
65
|
+
| `dvara_passed` | false once after keep-going mode carried on past a refusal, to guard a "✅" line |
|
|
66
|
+
|
|
67
|
+
`keep-going.sh` adds `dvara_keep_going_held`, `dvara_keep_going_skipped` and
|
|
68
|
+
`dvara_keep_going_report`. `DVARA_KEEP_GOING_NAME` and `DVARA_KEEP_GOING_TITLE`
|
|
69
|
+
rename it in its messages (default "preflight" and "Preflight").
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
`.dvara.json` at the repository's root, or the file `DVARA_CONFIG` names. Every
|
|
74
|
+
field is optional; an unknown field is an error.
|
|
75
|
+
|
|
76
|
+
| Field | Default | Meaning |
|
|
77
|
+
| ------------ | ------------------------------------------------ | -------------------------------------------------------------- |
|
|
78
|
+
| `historyDir` | `.dvara` | where the history, the quiet log and scratch files go |
|
|
79
|
+
| `quietHooks` | `["pre-commit"]` | hooks whose passing output is one line |
|
|
80
|
+
| `watchdog` | `null` | a watchdog module, relative to the root |
|
|
81
|
+
| `baseRefs` | `origin/main`, `origin/develop`, `origin/master` | the refs tried for the base a run is judged against |
|
|
82
|
+
| `stages` | `{}` | each hook's stages in order; `dvara stages <hook>` prints them |
|
|
83
|
+
|
|
84
|
+
The environment it reads: `DVARA_MODE=preflight` (a preflight's rows get kind
|
|
85
|
+
`preflight`), `DVARA_BACKGROUND=1` (started detached), `DVARA_MEASUREMENT_CONTEXT=manual-benchmark`,
|
|
86
|
+
`DVARA_BOOTSTRAP_SECONDS` (seconds the hook spent selecting its runtime before
|
|
87
|
+
starting dvara, counted as a stage), `DVARA_VERBOSE=1`. It sets
|
|
88
|
+
`DVARA_MEASURED_HOOK`, `DVARA_STAGE_FILE` and `DVARA_RECORD_FILE` for the hook.
|
|
89
|
+
|
|
90
|
+
## A watchdog module
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
export function createWatchdog(options: {
|
|
94
|
+
rootPid;
|
|
95
|
+
stageFile;
|
|
96
|
+
hangsDir;
|
|
97
|
+
runId;
|
|
98
|
+
config;
|
|
99
|
+
}): {
|
|
100
|
+
disarm(): void;
|
|
101
|
+
stop(): void;
|
|
102
|
+
summary(): { mode: string };
|
|
103
|
+
enforced(): boolean;
|
|
104
|
+
};
|
|
105
|
+
export function resolveWatchdogConfig(
|
|
106
|
+
env,
|
|
107
|
+
platform,
|
|
108
|
+
perfLevels: string[],
|
|
109
|
+
): unknown;
|
|
110
|
+
export function exitDisposition(
|
|
111
|
+
summary,
|
|
112
|
+
): { code: number; status: string } | null;
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Commands
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
dvara run <hook-file> [hook args…] # run a git hook under the supervisor
|
|
119
|
+
dvara stages <hook> # the stages .dvara.json lists for it
|
|
120
|
+
dvara shell <stages|keep-going> # the path of a shell helper
|
|
121
|
+
dvara --version
|
|
122
|
+
```
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// dvara: git hooks with cheap checks at commit and expensive ones once at push.
|
|
3
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { loadConfig } from "./config.js";
|
|
7
|
+
import { supervise } from "./supervisor.js";
|
|
8
|
+
export const USAGE = [
|
|
9
|
+
"usage: dvara run <hook-file> [hook args…] run a git hook under the supervisor",
|
|
10
|
+
" dvara stages <hook> the hook stages the config lists, one per line",
|
|
11
|
+
" dvara shell <stages|keep-going> the path of a shell helper to source",
|
|
12
|
+
" dvara --version",
|
|
13
|
+
].join("\n");
|
|
14
|
+
const SHELL_HELPERS = new Set(["stages", "keep-going"]);
|
|
15
|
+
export const ownVersion = () => {
|
|
16
|
+
const pkg = join(import.meta.dirname, "..", "package.json");
|
|
17
|
+
return JSON.parse(readFileSync(pkg, "utf8")).version;
|
|
18
|
+
};
|
|
19
|
+
/** The path of a shell helper this package ships. */
|
|
20
|
+
export const shellHelper = (name) => resolve(import.meta.dirname, "..", "shell", `${name}.sh`);
|
|
21
|
+
/** Runs one command; resolves with the process's exit code. */
|
|
22
|
+
export const run = async (argv, context) => {
|
|
23
|
+
const { io, cwd, env } = context;
|
|
24
|
+
const [command, ...rest] = argv;
|
|
25
|
+
if (command === "--version") {
|
|
26
|
+
io.out(ownVersion());
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
if (command === "shell" &&
|
|
30
|
+
rest.length === 1 &&
|
|
31
|
+
SHELL_HELPERS.has(rest[0] ?? "")) {
|
|
32
|
+
io.out(shellHelper(rest[0] ?? ""));
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
if ((command === "run" && rest.length > 0) ||
|
|
36
|
+
(command === "stages" && rest.length === 1)) {
|
|
37
|
+
// Git runs every hook at the top of the working tree.
|
|
38
|
+
let config;
|
|
39
|
+
try {
|
|
40
|
+
config = loadConfig(cwd, env);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
io.err(error.message);
|
|
44
|
+
return 2;
|
|
45
|
+
}
|
|
46
|
+
if (command === "stages") {
|
|
47
|
+
for (const name of config.stages[rest[0] ?? ""] ?? [])
|
|
48
|
+
io.out(name);
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
return (context.supervise ?? supervise)(rest, {
|
|
52
|
+
...context.options,
|
|
53
|
+
root: cwd,
|
|
54
|
+
config,
|
|
55
|
+
env,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
io.err(USAGE);
|
|
59
|
+
return 2;
|
|
60
|
+
};
|
|
61
|
+
/** Whether this module is the script node was asked to run, through any symlink. */
|
|
62
|
+
export const isEntry = (entry, moduleUrl) => {
|
|
63
|
+
if (!entry)
|
|
64
|
+
return false;
|
|
65
|
+
try {
|
|
66
|
+
return (realpathSync(resolve(entry)) === realpathSync(fileURLToPath(moduleUrl)));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
/** The process's own output, one line per call. */
|
|
73
|
+
export const processIo = {
|
|
74
|
+
out: (line) => process.stdout.write(`${line}\n`),
|
|
75
|
+
err: (line) => process.stderr.write(`${line}\n`),
|
|
76
|
+
};
|
|
77
|
+
if (isEntry(process.argv[1], import.meta.url)) {
|
|
78
|
+
process.exitCode = await run(process.argv.slice(2), {
|
|
79
|
+
io: processIo,
|
|
80
|
+
cwd: process.cwd(),
|
|
81
|
+
env: process.env,
|
|
82
|
+
});
|
|
83
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// A repository's dvara settings: `.dvara.json` at its root, or the file
|
|
2
|
+
// DVARA_CONFIG names (relative to the root). Every field is optional.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
export const DEFAULTS = {
|
|
6
|
+
historyDir: ".dvara",
|
|
7
|
+
quietHooks: ["pre-commit"],
|
|
8
|
+
watchdog: null,
|
|
9
|
+
baseRefs: ["origin/main", "origin/develop", "origin/master"],
|
|
10
|
+
stages: {},
|
|
11
|
+
};
|
|
12
|
+
export const CONFIG_FILE = ".dvara.json";
|
|
13
|
+
const strings = (value, field) => {
|
|
14
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string"))
|
|
15
|
+
throw new Error(`${field} must be a list of strings`);
|
|
16
|
+
return value;
|
|
17
|
+
};
|
|
18
|
+
/** The settings in `text` over the defaults; a field of the wrong type is an error, an unknown one too. */
|
|
19
|
+
export const parseConfig = (text) => {
|
|
20
|
+
const raw = JSON.parse(text);
|
|
21
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
22
|
+
throw new Error("the config must be an object");
|
|
23
|
+
const entries = raw;
|
|
24
|
+
const known = new Set(Object.keys(DEFAULTS));
|
|
25
|
+
for (const key of Object.keys(entries))
|
|
26
|
+
if (!known.has(key))
|
|
27
|
+
throw new Error(`unknown field ${key}`);
|
|
28
|
+
const config = { ...DEFAULTS };
|
|
29
|
+
if ("historyDir" in entries) {
|
|
30
|
+
if (typeof entries.historyDir !== "string" || entries.historyDir === "")
|
|
31
|
+
throw new Error("historyDir must be a non-empty string");
|
|
32
|
+
config.historyDir = entries.historyDir;
|
|
33
|
+
}
|
|
34
|
+
if ("quietHooks" in entries)
|
|
35
|
+
config.quietHooks = strings(entries.quietHooks, "quietHooks");
|
|
36
|
+
if ("watchdog" in entries) {
|
|
37
|
+
if (entries.watchdog !== null && typeof entries.watchdog !== "string")
|
|
38
|
+
throw new Error("watchdog must be a path or null");
|
|
39
|
+
config.watchdog = entries.watchdog;
|
|
40
|
+
}
|
|
41
|
+
if ("baseRefs" in entries)
|
|
42
|
+
config.baseRefs = strings(entries.baseRefs, "baseRefs");
|
|
43
|
+
if ("stages" in entries) {
|
|
44
|
+
const stages = entries.stages;
|
|
45
|
+
if (typeof stages !== "object" || stages === null || Array.isArray(stages))
|
|
46
|
+
throw new Error("stages must map each hook to its stage names");
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const [hook, names] of Object.entries(stages))
|
|
49
|
+
out[hook] = strings(names, `stages.${hook}`);
|
|
50
|
+
config.stages = out;
|
|
51
|
+
}
|
|
52
|
+
return config;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* The settings for `root`. No file is the defaults. A file that cannot be
|
|
56
|
+
* parsed is an error: a hook that silently ran on the defaults would write its
|
|
57
|
+
* history somewhere nobody reads.
|
|
58
|
+
*/
|
|
59
|
+
export const loadConfig = (root, env) => {
|
|
60
|
+
const path = resolve(root, env.DVARA_CONFIG || CONFIG_FILE);
|
|
61
|
+
let text;
|
|
62
|
+
try {
|
|
63
|
+
text = readFileSync(path, "utf8");
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error.code === "ENOENT" && !env.DVARA_CONFIG)
|
|
67
|
+
return DEFAULTS;
|
|
68
|
+
throw new Error(`dvara: cannot read ${path}: ${error.message}`);
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return parseConfig(text);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
throw new Error(`dvara: ${path}: ${error.message}`);
|
|
75
|
+
}
|
|
76
|
+
};
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Who started a run and on what machine: facts for the history row only.
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
const run = (file, args) => execFileSync(file, args, { encoding: "utf8", timeout: 5000 });
|
|
4
|
+
/**
|
|
5
|
+
* A preflight runs the pre-push hook ahead of a push but is not one: its rows
|
|
6
|
+
* get their own kind, so a reader of push rows never counts it. Git always
|
|
7
|
+
* passes a real push its remote, so one that inherited the mode stays a push.
|
|
8
|
+
*/
|
|
9
|
+
export const hookKind = (hookName, args, env) => hookName === "pre-push" && env.DVARA_MODE === "preflight" && args.length === 0
|
|
10
|
+
? "preflight"
|
|
11
|
+
: hookName;
|
|
12
|
+
/** Parent of each pid in a `ps -axo pid=,ppid=,comm=` table. */
|
|
13
|
+
export const processTable = (text) => {
|
|
14
|
+
const parents = new Map();
|
|
15
|
+
for (const line of text.trim().split("\n")) {
|
|
16
|
+
const match = /^(\d+)\s+(\d+)\s+(.+)$/.exec(line.trim());
|
|
17
|
+
if (!match)
|
|
18
|
+
continue;
|
|
19
|
+
const pid = Number(match[1]);
|
|
20
|
+
const parent = Number(match[2]);
|
|
21
|
+
// A digit run too long for a number is no process.
|
|
22
|
+
if (!Number.isFinite(pid) || !Number.isFinite(parent))
|
|
23
|
+
continue;
|
|
24
|
+
parents.set(pid, { parent, command: match[3] ?? "" });
|
|
25
|
+
}
|
|
26
|
+
return parents;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Who started this run: a benchmark, a background preflight, git, or a person
|
|
30
|
+
* (a parent chain that ends at init without passing git).
|
|
31
|
+
*/
|
|
32
|
+
export const invocationContext = (kind, env, ppid, exec = run) => {
|
|
33
|
+
if (env.DVARA_MEASUREMENT_CONTEXT === "manual-benchmark")
|
|
34
|
+
return "manual-benchmark";
|
|
35
|
+
// A detached background preflight's parent chain ends at init.
|
|
36
|
+
if (kind === "preflight" && env.DVARA_BACKGROUND === "1")
|
|
37
|
+
return "background";
|
|
38
|
+
try {
|
|
39
|
+
const parents = processTable(exec("ps", ["-axo", "pid=,ppid=,comm="]));
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
let pid = ppid;
|
|
42
|
+
for (let entry = parents.get(pid); entry && !seen.has(pid); entry = parents.get(pid)) {
|
|
43
|
+
seen.add(pid);
|
|
44
|
+
if (/(?:^|\/)git$/.test(entry.command))
|
|
45
|
+
return "git-hook";
|
|
46
|
+
pid = entry.parent;
|
|
47
|
+
}
|
|
48
|
+
return pid <= 1 ? "manual" : "unknown";
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return "unknown";
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
/** The CPU's performance-level names (macOS only), for a watchdog's mode. */
|
|
55
|
+
export const perfLevelNames = (platform, exec = run) => {
|
|
56
|
+
if (platform !== "darwin")
|
|
57
|
+
return [];
|
|
58
|
+
try {
|
|
59
|
+
const levels = Number(exec("sysctl", ["-n", "hw.nperflevels"]));
|
|
60
|
+
if (!Number.isFinite(levels))
|
|
61
|
+
return [];
|
|
62
|
+
return Array.from({ length: levels }, (_, i) => exec("sysctl", ["-n", `hw.perflevel${i}.name`]).trim());
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
};
|
package/dist/quiet.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Quiet on success. A passing commit hook that prints thirty lines of progress
|
|
2
|
+
// costs an agent session those lines on every later request, and says nothing
|
|
3
|
+
// one line cannot. The supervisor holds a quiet hook's output: on failure it is
|
|
4
|
+
// shown in full; on success it becomes the hook's warnings plus one summary
|
|
5
|
+
// line, and the full output is kept in a log. DVARA_VERBOSE=1 streams it.
|
|
6
|
+
/** Whether this run's output is held rather than streamed. */
|
|
7
|
+
export const isQuiet = (kind, quietHooks, env) => quietHooks.includes(kind) && env.DVARA_VERBOSE !== "1";
|
|
8
|
+
/**
|
|
9
|
+
* The warnings a passing run still shows: each line starting with ⚠ and the
|
|
10
|
+
* indented lines that continue it. A warning qualifies a pass; hiding it would
|
|
11
|
+
* turn the pass into an unqualified one.
|
|
12
|
+
*/
|
|
13
|
+
export const successNotices = (text) => {
|
|
14
|
+
const kept = [];
|
|
15
|
+
let inNotice = false;
|
|
16
|
+
for (const line of text.split("\n")) {
|
|
17
|
+
if (line.trimStart().startsWith("⚠")) {
|
|
18
|
+
inNotice = true;
|
|
19
|
+
kept.push(line);
|
|
20
|
+
}
|
|
21
|
+
else if (inNotice && /^\s+\S/.test(line)) {
|
|
22
|
+
kept.push(line);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
inNotice = false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return kept;
|
|
29
|
+
};
|
|
30
|
+
/** The one line a passing quiet run prints. */
|
|
31
|
+
export const successLine = (kind, durationMs, logPath) => `✅ ${kind} passed in ${(durationMs / 1000).toFixed(1)}s (full output: ${logPath})`;
|
package/dist/records.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// What a supervised hook tells its supervisor while it runs: stage markers
|
|
2
|
+
// (`dvara_stage`) and records (`dvara_record`), one line each in files the
|
|
3
|
+
// supervisor names. Parsed here into the history row's fields.
|
|
4
|
+
/**
|
|
5
|
+
* A duration field. Empty is unmeasured, not zero; text that is not a finite
|
|
6
|
+
* number is null too, as JSON would have written NaN and Infinity.
|
|
7
|
+
*/
|
|
8
|
+
export const recordedMs = (text) => {
|
|
9
|
+
if (!text)
|
|
10
|
+
return null;
|
|
11
|
+
const ms = Number(text);
|
|
12
|
+
return Number.isFinite(ms) ? ms : null;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Records, one tab-separated line each: `refs-source <stdin|none>`,
|
|
16
|
+
* `ref <local> <remote>`, `stage-input <stage> <kind> <digest>`,
|
|
17
|
+
* `proof <check> <outcome> <ms>`, and
|
|
18
|
+
* `proof-reason <check> <reason> <waitMs> <executionMs> <previousExecutionMs>`.
|
|
19
|
+
* Anything else is ignored.
|
|
20
|
+
*/
|
|
21
|
+
export const parseRecords = (text) => {
|
|
22
|
+
const declared = new Map();
|
|
23
|
+
const proof = [];
|
|
24
|
+
const proofDiagnostics = [];
|
|
25
|
+
if (text === null)
|
|
26
|
+
return { pushed: null, declared, proof, proofDiagnostics };
|
|
27
|
+
const pushed = { source: "none", refs: [] };
|
|
28
|
+
for (const line of text.split("\n").filter(Boolean)) {
|
|
29
|
+
const [type, ...f] = line.split("\t");
|
|
30
|
+
if (type === "refs-source")
|
|
31
|
+
pushed.source = f[0] ?? "";
|
|
32
|
+
else if (type === "ref")
|
|
33
|
+
pushed.refs.push({ local: f[0] ?? "", remote: f[1] ?? "" });
|
|
34
|
+
else if (type === "stage-input")
|
|
35
|
+
declared.set(f[0] ?? "", { kind: f[1] ?? "", digest: f[2] ?? "" });
|
|
36
|
+
else if (type === "proof") {
|
|
37
|
+
const ms = Number(f[2]);
|
|
38
|
+
proof.push({
|
|
39
|
+
check: f[0] ?? "",
|
|
40
|
+
outcome: f[1] ?? "",
|
|
41
|
+
ms: Number.isFinite(ms) ? ms : null,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
else if (type === "proof-reason")
|
|
45
|
+
proofDiagnostics.push({
|
|
46
|
+
check: f[0] ?? "",
|
|
47
|
+
reason: f[1] ?? "",
|
|
48
|
+
waitMs: recordedMs(f[2]),
|
|
49
|
+
executionMs: recordedMs(f[3]),
|
|
50
|
+
previousExecutionMs: recordedMs(f[4]),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return { pushed, declared, proof, proofDiagnostics };
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Stage markers, `<seconds>\t<name>` per line (bash's `$SECONDS`), offset by
|
|
57
|
+
* the time the hook spent choosing its runtime before the supervisor started,
|
|
58
|
+
* which becomes a stage of its own. A marker whose time is not a number stays
|
|
59
|
+
* NaN: JSON writes it as null, which voids its neighbours' durations rather
|
|
60
|
+
* than inventing them.
|
|
61
|
+
*/
|
|
62
|
+
export const parseStages = (text, bootstrapMs) => {
|
|
63
|
+
const stages = (text ?? "")
|
|
64
|
+
.trim()
|
|
65
|
+
.split("\n")
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.map((line) => {
|
|
68
|
+
const [seconds, name = ""] = line.split("\t");
|
|
69
|
+
const at = Number(seconds);
|
|
70
|
+
return {
|
|
71
|
+
name,
|
|
72
|
+
startMs: Number.isFinite(at) ? bootstrapMs + at * 1000 : Number.NaN,
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
if (bootstrapMs)
|
|
76
|
+
stages.unshift({ name: "runtime-selection", startMs: 0 });
|
|
77
|
+
return stages;
|
|
78
|
+
};
|
|
79
|
+
/** Resident memory (KiB) and process count of `rootPid`'s tree, from a `ps -axo pid=,ppid=,rss=` table. */
|
|
80
|
+
export const treeUsage = (table, rootPid) => {
|
|
81
|
+
const rows = table
|
|
82
|
+
.trim()
|
|
83
|
+
.split("\n")
|
|
84
|
+
.map((row) => row.trim().split(/\s+/).map(Number));
|
|
85
|
+
const ids = new Set([rootPid]);
|
|
86
|
+
// A Set visits what is added while iterating it: a breadth-first walk.
|
|
87
|
+
for (const id of ids)
|
|
88
|
+
for (const [pid, ppid] of rows)
|
|
89
|
+
if (ppid === id && pid !== undefined)
|
|
90
|
+
ids.add(pid);
|
|
91
|
+
let rssKiB = 0;
|
|
92
|
+
for (const [pid, , rss] of rows)
|
|
93
|
+
if (pid !== undefined && ids.has(pid))
|
|
94
|
+
rssKiB += rss ?? 0;
|
|
95
|
+
return { rssKiB, processes: ids.size };
|
|
96
|
+
};
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
// The hook supervisor: runs a git hook under bash, forwards the terminal's
|
|
10
|
+
// signals to it, and writes one history row per run. It never changes the
|
|
11
|
+
// hook's verdict except to report a signalled run as cancelled; every failure
|
|
12
|
+
// of its own (telemetry, watchdog, quiet output) is fail-open.
|
|
13
|
+
import { execFileSync, spawn, spawnSync, } from "node:child_process";
|
|
14
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
15
|
+
import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
16
|
+
import { cpus, totalmem } from "node:os";
|
|
17
|
+
import { relative, resolve } from "node:path";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
19
|
+
import { hookKind, invocationContext, perfLevelNames } from "./context.js";
|
|
20
|
+
import { isQuiet, successLine, successNotices } from "./quiet.js";
|
|
21
|
+
import { parseRecords, parseStages, treeUsage } from "./records.js";
|
|
22
|
+
/** Exit code per signal, shared by the pre-launch path and the exit handler. */
|
|
23
|
+
export const SIGNAL_EXIT_CODES = {
|
|
24
|
+
SIGINT: 130,
|
|
25
|
+
SIGTERM: 143,
|
|
26
|
+
SIGHUP: 129,
|
|
27
|
+
SIGQUIT: 131,
|
|
28
|
+
};
|
|
29
|
+
const FORWARDED = Object.keys(SIGNAL_EXIT_CODES);
|
|
30
|
+
// A hung `ps` must not hang the supervisor that exists to report hangs.
|
|
31
|
+
const psTable = () => execFileSync("ps", ["-axo", "pid=,ppid=,rss="], {
|
|
32
|
+
encoding: "utf8",
|
|
33
|
+
timeout: 10_000,
|
|
34
|
+
});
|
|
35
|
+
const readOrNull = (path) => {
|
|
36
|
+
try {
|
|
37
|
+
return readFileSync(path, "utf8");
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const importWatchdog = async (path) => (await import(__rewriteRelativeImportExtension(pathToFileURL(path).href)));
|
|
44
|
+
/** What a run is judged against, read once before the hook starts. */
|
|
45
|
+
export const judgedAgainst = (root, env, baseRefs) => {
|
|
46
|
+
const git = (args) => {
|
|
47
|
+
try {
|
|
48
|
+
// A fact git cannot give is null; its complaint is not the hook's output.
|
|
49
|
+
return execFileSync("git", args, {
|
|
50
|
+
cwd: root,
|
|
51
|
+
env,
|
|
52
|
+
stdio: "pipe",
|
|
53
|
+
}).toString();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const revision = git(["rev-parse", "HEAD"]);
|
|
60
|
+
// A detached HEAD abbreviates to the literal "HEAD", which is no branch.
|
|
61
|
+
const branchName = git(["rev-parse", "--abbrev-ref", "HEAD"])?.trim();
|
|
62
|
+
const branch = !branchName || branchName === "HEAD" ? null : branchName;
|
|
63
|
+
const diff = revision === null ? null : git(["diff", "--binary", "HEAD"]);
|
|
64
|
+
// Diagnostic identity only: it never authorizes reusing a result.
|
|
65
|
+
const fingerprint = revision === null || diff === null
|
|
66
|
+
? null
|
|
67
|
+
: createHash("sha256").update(revision).update(diff).digest("hex");
|
|
68
|
+
// The merge-base is taken with the captured commit, not the ref, so a peer's
|
|
69
|
+
// fetch between the two calls cannot pair two versions of the base. Exit 1
|
|
70
|
+
// is "no common ancestor", a real state; any other failure is unknown.
|
|
71
|
+
const mergeBaseOf = (commit) => {
|
|
72
|
+
const result = spawnSync("git", ["merge-base", commit, "HEAD"], {
|
|
73
|
+
cwd: root,
|
|
74
|
+
env,
|
|
75
|
+
encoding: "utf8",
|
|
76
|
+
});
|
|
77
|
+
if (result.status === 0)
|
|
78
|
+
return result.stdout.trim() || null;
|
|
79
|
+
return result.status === 1 ? "none" : null;
|
|
80
|
+
};
|
|
81
|
+
let base = null;
|
|
82
|
+
for (const ref of baseRefs) {
|
|
83
|
+
const commit = git([
|
|
84
|
+
"rev-parse",
|
|
85
|
+
"--verify",
|
|
86
|
+
"--quiet",
|
|
87
|
+
`${ref}^{commit}`,
|
|
88
|
+
])?.trim();
|
|
89
|
+
if (commit) {
|
|
90
|
+
base = { ref, commit, mergeBase: mergeBaseOf(commit) };
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const tree = git(["rev-parse", "HEAD^{tree}"])?.trim() || null;
|
|
95
|
+
const untracked = git(["ls-files", "--others", "--exclude-standard"]);
|
|
96
|
+
const clean = diff === null || untracked === null
|
|
97
|
+
? null
|
|
98
|
+
: diff === "" && untracked === "";
|
|
99
|
+
return { branch, fingerprint, base, tree, clean };
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Run `hook` under supervision and resolve with the exit code git must see.
|
|
103
|
+
* One row per run goes to `<root>/<historyDir>/history.jsonl`.
|
|
104
|
+
*/
|
|
105
|
+
export const supervise = async ([hook, ...args], options) => {
|
|
106
|
+
if (hook === undefined)
|
|
107
|
+
throw new Error("supervise: no hook given");
|
|
108
|
+
const { root, config, env = process.env, signals = process, exit = (code) => process.exit(code), platform = process.platform, sampleIntervalMs = 5000, drainLimitMs = 2000, ppid = process.ppid, loadWatchdog = importWatchdog, stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), processTable = psTable, } = options;
|
|
109
|
+
const dir = resolve(root, config.historyDir);
|
|
110
|
+
try {
|
|
111
|
+
mkdirSync(dir, { recursive: true });
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
/* telemetry cannot block the hook */
|
|
115
|
+
}
|
|
116
|
+
const id = randomUUID();
|
|
117
|
+
const events = resolve(dir, `${id}.stages`);
|
|
118
|
+
const records = resolve(dir, `${id}.records`);
|
|
119
|
+
// The seconds the hook spent selecting its runtime before it started this
|
|
120
|
+
// supervisor; unset, junk and negative count as none.
|
|
121
|
+
const bootstrapSeconds = Number(env.DVARA_BOOTSTRAP_SECONDS);
|
|
122
|
+
const bootstrapMs = Math.max(0, Number.isFinite(bootstrapSeconds) ? bootstrapSeconds : 0) *
|
|
123
|
+
1000;
|
|
124
|
+
const started = Date.now() - bootstrapMs;
|
|
125
|
+
const hookName = hook.split("/").at(-1) ?? hook;
|
|
126
|
+
const kind = hookKind(hookName, args, env);
|
|
127
|
+
const invocation = invocationContext(kind, env, ppid);
|
|
128
|
+
// The handlers go in BEFORE the hook is spawned and before the first await.
|
|
129
|
+
// The child is detached, in its own process group, so it does not receive
|
|
130
|
+
// the terminal's signals: forwarding them is the only thing that stops it.
|
|
131
|
+
// Registered after the spawn, a signal in between kills this supervisor by
|
|
132
|
+
// default instead, writing no row and orphaning the hook. Under load that
|
|
133
|
+
// window opens wide.
|
|
134
|
+
let child;
|
|
135
|
+
let watchdog;
|
|
136
|
+
let signal = null;
|
|
137
|
+
const listeners = [];
|
|
138
|
+
for (const name of FORWARDED) {
|
|
139
|
+
const listener = () => {
|
|
140
|
+
signal = name;
|
|
141
|
+
// A person cancelled the run: the watchdog must neither act on nor relabel it.
|
|
142
|
+
watchdog?.disarm();
|
|
143
|
+
if (child?.pid === undefined) {
|
|
144
|
+
// Nothing has run, so there is no group to signal and no row to write.
|
|
145
|
+
exit(SIGNAL_EXIT_CODES[name] ?? 1);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
process.kill(-child.pid, name);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (error.code !== "ESRCH") {
|
|
153
|
+
stderr(`Process-group signal forwarding failed: ${error.code}; forwarding to the hook directly.\n`);
|
|
154
|
+
child.kill(name);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
listeners.push([name, listener]);
|
|
159
|
+
signals.on(name, listener);
|
|
160
|
+
}
|
|
161
|
+
// A settled run answers no more signals: each run removes only its own.
|
|
162
|
+
const release = () => {
|
|
163
|
+
for (const [name, listener] of listeners)
|
|
164
|
+
signals.off(name, listener);
|
|
165
|
+
};
|
|
166
|
+
const { branch, fingerprint, base, tree, clean } = judgedAgainst(root, env, config.baseRefs);
|
|
167
|
+
let watchdogModule = null;
|
|
168
|
+
if (config.watchdog) {
|
|
169
|
+
try {
|
|
170
|
+
watchdogModule = await loadWatchdog(resolve(root, config.watchdog));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
/* the watchdog is a safety net, never a prerequisite for running the hook */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Signalled while the watchdog loaded: `exit` has ended the CLI already.
|
|
177
|
+
if (signal) {
|
|
178
|
+
release();
|
|
179
|
+
return SIGNAL_EXIT_CODES[signal] ?? 1;
|
|
180
|
+
}
|
|
181
|
+
const quiet = isQuiet(kind, config.quietHooks, env);
|
|
182
|
+
const held = [];
|
|
183
|
+
const spawned = spawn("bash", [hook, ...args], {
|
|
184
|
+
cwd: root,
|
|
185
|
+
// stdin stays inherited: pre-push reads its refs from it.
|
|
186
|
+
stdio: quiet ? ["inherit", "pipe", "pipe"] : "inherit",
|
|
187
|
+
detached: true,
|
|
188
|
+
env: {
|
|
189
|
+
...env,
|
|
190
|
+
DVARA_MEASURED_HOOK: hookName,
|
|
191
|
+
DVARA_STAGE_FILE: events,
|
|
192
|
+
DVARA_RECORD_FILE: records,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
child = spawned;
|
|
196
|
+
if (quiet)
|
|
197
|
+
for (const stream of [spawned.stdout, spawned.stderr])
|
|
198
|
+
stream?.on("data", (chunk) => held.push(chunk));
|
|
199
|
+
if (watchdogModule && spawned.pid !== undefined) {
|
|
200
|
+
try {
|
|
201
|
+
watchdog = watchdogModule.createWatchdog({
|
|
202
|
+
rootPid: spawned.pid,
|
|
203
|
+
stageFile: events,
|
|
204
|
+
hangsDir: resolve(dir, "hangs"),
|
|
205
|
+
runId: id,
|
|
206
|
+
config: watchdogModule.resolveWatchdogConfig(env, platform, perfLevelNames(platform)),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
watchdog = undefined;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
let peakTreeRssKiB = 0;
|
|
214
|
+
let peakProcesses = 0;
|
|
215
|
+
let resourceSamples = 0;
|
|
216
|
+
const sample = () => {
|
|
217
|
+
try {
|
|
218
|
+
const table = processTable();
|
|
219
|
+
const usage = treeUsage(table, spawned.pid ?? -1);
|
|
220
|
+
peakTreeRssKiB = Math.max(peakTreeRssKiB, usage.rssKiB);
|
|
221
|
+
peakProcesses = Math.max(peakProcesses, usage.processes);
|
|
222
|
+
resourceSamples++;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
/* an unavailable measurement is not a verdict */
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
const timer = setInterval(sample, sampleIntervalMs);
|
|
229
|
+
/** A quiet hook's held output: all of it when the run did not pass, else its warnings and one line. */
|
|
230
|
+
const report = (code, durationMs) => {
|
|
231
|
+
const output = Buffer.concat(held);
|
|
232
|
+
const log = resolve(dir, `${kind}.log`);
|
|
233
|
+
try {
|
|
234
|
+
writeFileSync(log, output);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
/* the log is a convenience; the verdict is printed regardless */
|
|
238
|
+
}
|
|
239
|
+
if (code !== 0) {
|
|
240
|
+
stderr(output);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const lines = [
|
|
244
|
+
...successNotices(output.toString("utf8")),
|
|
245
|
+
successLine(kind, durationMs, relative(root, log)),
|
|
246
|
+
];
|
|
247
|
+
stdout(`${lines.join("\n")}\n`);
|
|
248
|
+
};
|
|
249
|
+
/**
|
|
250
|
+
* Wait for a quiet hook's pipes to empty after it exits. A process the hook
|
|
251
|
+
* left running can hold them open forever, so the wait is bounded and the
|
|
252
|
+
* pipes are then closed.
|
|
253
|
+
*/
|
|
254
|
+
const drained = async () => {
|
|
255
|
+
if (!quiet)
|
|
256
|
+
return;
|
|
257
|
+
const streams = [spawned.stdout, spawned.stderr].filter((s) => s !== null);
|
|
258
|
+
const closed = Promise.all(streams.map((stream) => stream.closed
|
|
259
|
+
? null
|
|
260
|
+
: new Promise((done) => stream.once("close", done))));
|
|
261
|
+
const limit = new Promise((done) => setTimeout(() => done("limit"), drainLimitMs).unref());
|
|
262
|
+
const outcome = await Promise.race([closed, limit]);
|
|
263
|
+
if (outcome === "limit")
|
|
264
|
+
held.push(Buffer.from(`[output truncated: a process the hook started still held its output open ${drainLimitMs / 1000} s after it exited]\n`));
|
|
265
|
+
for (const stream of streams)
|
|
266
|
+
stream.destroy();
|
|
267
|
+
};
|
|
268
|
+
const finish = (exitCode, error) => {
|
|
269
|
+
let code = exitCode;
|
|
270
|
+
clearInterval(timer);
|
|
271
|
+
watchdog?.stop();
|
|
272
|
+
const watchdogSummary = watchdog?.summary() ?? {
|
|
273
|
+
mode: "unavailable",
|
|
274
|
+
};
|
|
275
|
+
// An enforced stop decides the outcome: the hook exited because the
|
|
276
|
+
// watchdog stopped it, so its own status is not the story.
|
|
277
|
+
const imposed = watchdogModule?.exitDisposition(watchdogSummary) ?? null;
|
|
278
|
+
if (imposed)
|
|
279
|
+
code = imposed.code;
|
|
280
|
+
const ended = Date.now();
|
|
281
|
+
if (quiet)
|
|
282
|
+
report(code, ended - started);
|
|
283
|
+
const marks = parseStages(readOrNull(events), bootstrapMs);
|
|
284
|
+
const { pushed, declared, proof, proofDiagnostics } = parseRecords(readOrNull(records));
|
|
285
|
+
const verdict = (last) => imposed
|
|
286
|
+
? imposed.status
|
|
287
|
+
: signal
|
|
288
|
+
? "cancelled"
|
|
289
|
+
: code === 0
|
|
290
|
+
? "success"
|
|
291
|
+
: last
|
|
292
|
+
? "failed"
|
|
293
|
+
: "failure";
|
|
294
|
+
// A stage's input is what it declared, else the whole tree when that tree
|
|
295
|
+
// is exactly a commit: coarse, but recomputable.
|
|
296
|
+
const treeInput = clean && tree ? { kind: "tree", digest: tree } : null;
|
|
297
|
+
const stages = marks.map((stage, index) => ({
|
|
298
|
+
...stage,
|
|
299
|
+
input: declared.get(stage.name) ?? treeInput,
|
|
300
|
+
durationMs: Math.max(0, (marks[index + 1]?.startMs ?? ended - started) - stage.startMs),
|
|
301
|
+
status: index === marks.length - 1 ? verdict(true) : "completed",
|
|
302
|
+
}));
|
|
303
|
+
try {
|
|
304
|
+
appendFileSync(resolve(dir, "history.jsonl"), `${JSON.stringify({
|
|
305
|
+
schema: 1,
|
|
306
|
+
id,
|
|
307
|
+
kind,
|
|
308
|
+
invocation,
|
|
309
|
+
startedAt: new Date(started).toISOString(),
|
|
310
|
+
durationMs: ended - started,
|
|
311
|
+
exitCode: code,
|
|
312
|
+
signal,
|
|
313
|
+
error,
|
|
314
|
+
status: verdict(false),
|
|
315
|
+
branch,
|
|
316
|
+
fingerprint,
|
|
317
|
+
fingerprintPurpose: "diagnostic-head-and-tracked-diff-only",
|
|
318
|
+
base,
|
|
319
|
+
tree,
|
|
320
|
+
clean,
|
|
321
|
+
pushed,
|
|
322
|
+
stages,
|
|
323
|
+
proof,
|
|
324
|
+
proofDiagnostics,
|
|
325
|
+
node: process.version,
|
|
326
|
+
platform: process.platform,
|
|
327
|
+
arch: process.arch,
|
|
328
|
+
cpus: cpus().length,
|
|
329
|
+
totalMemoryBytes: totalmem(),
|
|
330
|
+
sampledPeakTreeRssKiB: peakTreeRssKiB || null,
|
|
331
|
+
peakProcesses: peakProcesses || null,
|
|
332
|
+
resourceSamples,
|
|
333
|
+
sampleIntervalMs,
|
|
334
|
+
watchdog: watchdogSummary,
|
|
335
|
+
})}\n`);
|
|
336
|
+
rmSync(events, { force: true });
|
|
337
|
+
rmSync(records, { force: true });
|
|
338
|
+
}
|
|
339
|
+
catch (failure) {
|
|
340
|
+
stderr(`History unavailable: ${failure.message}\n`);
|
|
341
|
+
}
|
|
342
|
+
return code;
|
|
343
|
+
};
|
|
344
|
+
// One row per run, whichever of `error` and `exit` arrives first.
|
|
345
|
+
let outcome;
|
|
346
|
+
return new Promise((done, fail) => {
|
|
347
|
+
const settle = (code, error) => {
|
|
348
|
+
try {
|
|
349
|
+
outcome ??= finish(code, error);
|
|
350
|
+
done(outcome);
|
|
351
|
+
}
|
|
352
|
+
catch (failure) {
|
|
353
|
+
fail(failure);
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
release();
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
spawned.on("error", (error) => settle(127, error.message));
|
|
360
|
+
spawned.on("exit", (code, childSignal) => {
|
|
361
|
+
// The watchdog's own stop is not a cancellation; `finish` reports its verdict instead.
|
|
362
|
+
if (!watchdog?.enforced())
|
|
363
|
+
signal ??= childSignal;
|
|
364
|
+
// A signal outranks the code the hook chose: a hook that traps its
|
|
365
|
+
// signal and exits 0 was still cancelled, and reporting 0 would let a
|
|
366
|
+
// push through a gate its operator had just interrupted.
|
|
367
|
+
const verdict = (signal ? SIGNAL_EXIT_CODES[signal] : undefined) ?? code ?? 1;
|
|
368
|
+
void drained().then(() => settle(verdict));
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veda-hq/dvara",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Git hooks for repositories coding agents work in: runs each hook under a supervisor that times its stages, forwards signals and keeps one history row per run, with quiet output on success and a keep-going mode for preflights.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"git hooks",
|
|
7
|
+
"pre-push",
|
|
8
|
+
"pre-commit",
|
|
9
|
+
"coding agents",
|
|
10
|
+
"telemetry"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=24.15"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/veda-hq/veda.git",
|
|
20
|
+
"directory": "packages/dvara"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/veda-hq/veda/tree/main/packages/dvara#readme",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"provenance": true
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"dvara": "dist/cli.js"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": "./dist/supervisor.js",
|
|
32
|
+
"./config": "./dist/config.js",
|
|
33
|
+
"./shell/stages.sh": "./shell/stages.sh",
|
|
34
|
+
"./shell/keep-going.sh": "./shell/keep-going.sh",
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"shell/*.sh"
|
|
40
|
+
],
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "24.19.2",
|
|
43
|
+
"typescript": "7.0.2"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc -p tsconfig.build.json",
|
|
47
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
48
|
+
"test": "node --test --experimental-test-coverage --test-coverage-lines=95 --test-coverage-functions=95 --test-coverage-branches=90 --test-coverage-exclude=src/**/*.test.ts --test-coverage-include=src/**/*.ts src/**/*.test.ts",
|
|
49
|
+
"smoke": "node dist/cli.js --version && node dist/cli.js shell stages",
|
|
50
|
+
"check": "pnpm typecheck && pnpm test && pnpm build && pnpm smoke"
|
|
51
|
+
}
|
|
6
52
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# shellcheck shell=bash
|
|
2
|
+
# Keep-going mode, for a preflight: the push hook itself, run ahead of the
|
|
3
|
+
# push, so the preflight and the push cannot drift apart. Only what a refusal
|
|
4
|
+
# does differs. In a push, a stage's `exit 1` ends the hook at its first
|
|
5
|
+
# failure. Here the same `exit 1` is recorded against the stage that raised it
|
|
6
|
+
# (DVARA_CURRENT_STAGE, stages.sh) and the hook carries on, so one run lists
|
|
7
|
+
# every failure. dvara_keep_going_report ends the run.
|
|
8
|
+
#
|
|
9
|
+
# Source it only in keep-going mode, after the runtimes the hook needs are in
|
|
10
|
+
# place, so a missing runtime still ends the run at once.
|
|
11
|
+
#
|
|
12
|
+
# How: a function named `exit` runs in place of the builtin. It takes over one
|
|
13
|
+
# case only, status 1 in the hook's own shell. In a subshell or a command
|
|
14
|
+
# substitution (BASH_SUBSHELL > 0) `exit` must still end that subshell, and
|
|
15
|
+
# every other status (0, a trap's 130 or 143, a probe's 124) is a real end, so
|
|
16
|
+
# those go to the builtin. Whatever `set -e` aborts on never calls `exit` and
|
|
17
|
+
# still ends the run. A stage written as a function keeps this behaviour: the
|
|
18
|
+
# function runs in the hook's shell, and execution continues after the `exit`.
|
|
19
|
+
#
|
|
20
|
+
# Plain strings, not arrays: macOS runs hooks under /bin/bash 3.2, where an
|
|
21
|
+
# empty array is an unbound variable under `set -u`.
|
|
22
|
+
|
|
23
|
+
DVARA_KEEP_GOING_FAILED=""
|
|
24
|
+
DVARA_KEEP_GOING_HELD=""
|
|
25
|
+
DVARA_KEEP_GOING_SKIPPED=""
|
|
26
|
+
|
|
27
|
+
# shellcheck disable=SC2034 # DVARA_UNREPORTED_REFUSAL is read by stages.sh
|
|
28
|
+
exit() {
|
|
29
|
+
local status="${1:-$?}"
|
|
30
|
+
if [ "$status" = 1 ] && [ "${BASH_SUBSHELL:-0}" -eq 0 ]; then
|
|
31
|
+
local stage="${DVARA_CURRENT_STAGE:-unknown}"
|
|
32
|
+
DVARA_KEEP_GOING_FAILED="${DVARA_KEEP_GOING_FAILED}${stage}"$'\n'
|
|
33
|
+
DVARA_UNREPORTED_REFUSAL=1
|
|
34
|
+
echo "✗ ${DVARA_KEEP_GOING_NAME:-preflight}: ${stage} failed; carrying on to the next check"
|
|
35
|
+
return 0
|
|
36
|
+
fi
|
|
37
|
+
builtin exit "$status"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# A check not run because an earlier result makes its verdict meaningless.
|
|
41
|
+
dvara_keep_going_held() {
|
|
42
|
+
DVARA_KEEP_GOING_HELD="${DVARA_KEEP_GOING_HELD}$1: not run: $2"$'\n'
|
|
43
|
+
echo "→ $1: not run: $2"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# A check this run was asked not to run: not a failure, and not a pass.
|
|
47
|
+
dvara_keep_going_skipped() {
|
|
48
|
+
DVARA_KEEP_GOING_SKIPPED="${DVARA_KEEP_GOING_SKIPPED}$1: skipped: $2"$'\n'
|
|
49
|
+
echo "⏭ $1: skipped: $2"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# The end of the run: every check skipped, held and failed, and the verdict.
|
|
53
|
+
# Fails when any check failed or was held; passes, saying so, when the only
|
|
54
|
+
# gaps are skips.
|
|
55
|
+
dvara_keep_going_report() {
|
|
56
|
+
local line failed=0
|
|
57
|
+
echo ""
|
|
58
|
+
echo "━━━ ${DVARA_KEEP_GOING_TITLE:-Preflight} summary ━━━"
|
|
59
|
+
while IFS= read -r line; do
|
|
60
|
+
if [ -n "$line" ]; then echo " ⏭ ${line}"; fi
|
|
61
|
+
done <<< "$DVARA_KEEP_GOING_SKIPPED"
|
|
62
|
+
while IFS= read -r line; do
|
|
63
|
+
if [ -n "$line" ]; then echo " ⏸ ${line}"; fi
|
|
64
|
+
done <<< "$DVARA_KEEP_GOING_HELD"
|
|
65
|
+
while IFS= read -r line; do
|
|
66
|
+
if [ -n "$line" ]; then
|
|
67
|
+
echo " ❌ ${line}"
|
|
68
|
+
failed=$((failed + 1))
|
|
69
|
+
fi
|
|
70
|
+
done <<< "$DVARA_KEEP_GOING_FAILED"
|
|
71
|
+
if [ "$failed" -gt 0 ]; then
|
|
72
|
+
echo " ${failed} check(s) failed. Fix them all, commit, and run the preflight again."
|
|
73
|
+
echo " Nothing was pushed."
|
|
74
|
+
builtin exit 1
|
|
75
|
+
fi
|
|
76
|
+
if [ -n "$DVARA_KEEP_GOING_HELD" ]; then
|
|
77
|
+
echo " No check failed, but the checks above did not run. Nothing was pushed."
|
|
78
|
+
builtin exit 1
|
|
79
|
+
fi
|
|
80
|
+
if [ -n "$DVARA_KEEP_GOING_SKIPPED" ]; then
|
|
81
|
+
echo " ✅ Every check that ran passed; the push runs the skipped ones. Nothing was pushed."
|
|
82
|
+
builtin exit 0
|
|
83
|
+
fi
|
|
84
|
+
echo " ✅ Every check passed. Nothing was pushed and no pull request was opened."
|
|
85
|
+
builtin exit 0
|
|
86
|
+
}
|
package/shell/stages.sh
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# shellcheck shell=bash
|
|
2
|
+
# dvara's hook helpers, sourced by a hook that runs under `dvara run`.
|
|
3
|
+
# Builtins only: one-second resolution, no subprocesses, and a failed write
|
|
4
|
+
# never fails the hook. Outside the supervisor the files are unset and every
|
|
5
|
+
# helper is a no-op, so the hook runs the same either way.
|
|
6
|
+
|
|
7
|
+
# Mark the start of a stage. DVARA_CURRENT_STAGE is the stage a refusal is
|
|
8
|
+
# recorded against in keep-going mode (keep-going.sh).
|
|
9
|
+
# shellcheck disable=SC2034 # read by keep-going.sh
|
|
10
|
+
dvara_stage() {
|
|
11
|
+
DVARA_CURRENT_STAGE="$1"
|
|
12
|
+
if [ -n "${DVARA_STAGE_FILE:-}" ]; then
|
|
13
|
+
printf '%s\t%s\n' "$SECONDS" "$1" >> "$DVARA_STAGE_FILE" || true
|
|
14
|
+
fi
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# A fact for the run's history row that only the hook knows: its fields,
|
|
18
|
+
# tab-separated, one record per call (records.ts lists the kinds).
|
|
19
|
+
dvara_record() {
|
|
20
|
+
if [ -n "${DVARA_RECORD_FILE:-}" ]; then
|
|
21
|
+
local IFS=$'\t'
|
|
22
|
+
printf '%s\n' "$*" >> "$DVARA_RECORD_FILE" || true
|
|
23
|
+
fi
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Record the commits a push updates, from the ref lines git passes pre-push
|
|
27
|
+
# on stdin ("<local_ref> <local_sha> <remote_ref> <remote_sha>"): each line's
|
|
28
|
+
# two commit ids, never its ref names. An empty argument means a manual run.
|
|
29
|
+
dvara_record_refs() {
|
|
30
|
+
local _local_ref local_sha _remote_ref remote_sha
|
|
31
|
+
if [ -z "${1:-}" ]; then
|
|
32
|
+
dvara_record refs-source none
|
|
33
|
+
return 0
|
|
34
|
+
fi
|
|
35
|
+
dvara_record refs-source stdin
|
|
36
|
+
while read -r _local_ref local_sha _remote_ref remote_sha; do
|
|
37
|
+
dvara_record ref "$local_sha" "$remote_sha"
|
|
38
|
+
done <<< "$1"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Guards a "✅" line: false once after keep-going mode carried on past a
|
|
42
|
+
# refusal, so a failed check is never reported as passed. A run that stops at
|
|
43
|
+
# its first failure never carries on, so there it is always true.
|
|
44
|
+
dvara_passed() {
|
|
45
|
+
if [ "${DVARA_UNREPORTED_REFUSAL:-0}" = 1 ]; then
|
|
46
|
+
DVARA_UNREPORTED_REFUSAL=0
|
|
47
|
+
return 1
|
|
48
|
+
fi
|
|
49
|
+
return 0
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
dvara_stage startup
|