@workit-dev/core 0.0.1 → 1.0.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 +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
package/src/checks.ts
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
// Check runs for `workit check` (design §2.1 S9, §2.2; D5, D14): no shell
|
|
2
|
+
// (unless the caller passes one string with --shell), the child's output
|
|
3
|
+
// streams through while a bounded, redacted copy is kept for the log blob
|
|
4
|
+
// (`<store>/blobs/logs/<sha256>.log`) and the tail. Named-check config lives
|
|
5
|
+
// in check-config.ts.
|
|
6
|
+
//
|
|
7
|
+
// Plain TS over node built-ins: no zod, no task store.
|
|
8
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { StringDecoder } from "node:string_decoder";
|
|
14
|
+
import { redactText } from "./forge/redact";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// bounded, redacted capture
|
|
18
|
+
|
|
19
|
+
/** Bytes of combined output kept for the log blob (the newest bytes win). */
|
|
20
|
+
export const MAX_LOG_BYTES = 2 * 1024 * 1024;
|
|
21
|
+
export const TAIL_LINES = 80;
|
|
22
|
+
const MAX_LINE_CHARS = 400;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Mask secrets and strip terminal escapes from captured output with the forge
|
|
26
|
+
* redaction (tokens, key=value and quoted secrets, URL credentials, private
|
|
27
|
+
* keys, signed URLs, credential-looking base64). The logger's redaction is not
|
|
28
|
+
* used: it rewrites Windows backslashes and home paths, which would alter the
|
|
29
|
+
* observed output.
|
|
30
|
+
*/
|
|
31
|
+
export const redactLog = (text: string): string => redactText(text);
|
|
32
|
+
|
|
33
|
+
/** The last `lines` non-empty lines, each cut to a bounded width. */
|
|
34
|
+
export function tailLines(text: string, lines: number = TAIL_LINES): string[] {
|
|
35
|
+
const all = text.replace(/\r\n?/gu, "\n").split("\n");
|
|
36
|
+
while (all.length && !all.at(-1)?.trim()) all.pop();
|
|
37
|
+
return all
|
|
38
|
+
.slice(-lines)
|
|
39
|
+
.map((line) => (line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}…` : line));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Keeps the newest `max` bytes of already-redacted text. */
|
|
43
|
+
export class TailBuffer {
|
|
44
|
+
private chunks: string[] = [];
|
|
45
|
+
private size = 0;
|
|
46
|
+
total = 0;
|
|
47
|
+
constructor(private readonly max: number) {}
|
|
48
|
+
push(text: string): void {
|
|
49
|
+
const bytes = Buffer.byteLength(text);
|
|
50
|
+
this.total += bytes;
|
|
51
|
+
this.chunks.push(text);
|
|
52
|
+
this.size += bytes;
|
|
53
|
+
while (this.chunks.length > 1 && this.size - Buffer.byteLength(this.chunks[0]) >= this.max)
|
|
54
|
+
this.size -= Buffer.byteLength(this.chunks.shift()!);
|
|
55
|
+
}
|
|
56
|
+
text(): string {
|
|
57
|
+
const joined = Buffer.from(this.chunks.join(""));
|
|
58
|
+
if (joined.length <= this.max) return joined.toString("utf8");
|
|
59
|
+
const value = joined.subarray(joined.length - this.max).toString("utf8");
|
|
60
|
+
// The head was cut: drop the partial first line.
|
|
61
|
+
const newline = value.indexOf("\n");
|
|
62
|
+
return newline >= 0 ? value.slice(newline + 1) : value;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const PEM_BEGIN = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/u;
|
|
67
|
+
const PEM_END = /-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/u;
|
|
68
|
+
const BATCH_BYTES = 64 * 1024;
|
|
69
|
+
const MAX_PENDING = 64 * 1024;
|
|
70
|
+
|
|
71
|
+
/** Lines that can belong to a PEM body: base64 (or blank), or an RFC 1421 encapsulated header. */
|
|
72
|
+
const PEM_BODY = /^(?:[A-Za-z0-9+/=]*|(?:Proc-Type|DEK-Info|Comment): .*)\s*$/u;
|
|
73
|
+
/** Suppression bounds for a key whose END never appears (truncated output). */
|
|
74
|
+
const MAX_KEY_LINES = 4096;
|
|
75
|
+
const MAX_KEY_BYTES = 256 * 1024;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Redacts output as it streams, before anything is bounded: complete lines
|
|
79
|
+
* are redacted in batches, and a private key block is replaced as a whole
|
|
80
|
+
* while its BEGIN line is still in view, so trimming the log to its newest
|
|
81
|
+
* bytes can never cut a key loose from its header. Key state spans the merged
|
|
82
|
+
* stdout+stderr log: while a key is open, base64-looking lines from either
|
|
83
|
+
* stream are dropped. The key ends at its END line, at the first non-base64
|
|
84
|
+
* line from the stream that began it, or after MAX_KEY_LINES/MAX_KEY_BYTES, so
|
|
85
|
+
* a key without an END never swallows the rest of the log. Non-key lines from
|
|
86
|
+
* the other stream pass through. A line longer than 64 KB is treated as complete.
|
|
87
|
+
*/
|
|
88
|
+
export class StreamRedactor {
|
|
89
|
+
private pending = new Map<string, string>();
|
|
90
|
+
private key: { stream: string; lines: number; bytes: number } | null = null;
|
|
91
|
+
private batch: string[] = [];
|
|
92
|
+
private batchBytes = 0;
|
|
93
|
+
constructor(private readonly sink: (text: string) => void) {}
|
|
94
|
+
write(stream: string, text: string): void {
|
|
95
|
+
let rest = (this.pending.get(stream) ?? "") + text;
|
|
96
|
+
for (let newline = rest.indexOf("\n"); newline >= 0; newline = rest.indexOf("\n")) {
|
|
97
|
+
this.line(stream, rest.slice(0, newline + 1));
|
|
98
|
+
rest = rest.slice(newline + 1);
|
|
99
|
+
}
|
|
100
|
+
if (rest.length > MAX_PENDING) {
|
|
101
|
+
this.line(stream, rest);
|
|
102
|
+
rest = "";
|
|
103
|
+
}
|
|
104
|
+
this.pending.set(stream, rest);
|
|
105
|
+
}
|
|
106
|
+
end(): void {
|
|
107
|
+
for (const [stream, rest] of this.pending) if (rest) this.line(stream, `${rest}\n`);
|
|
108
|
+
this.pending.clear();
|
|
109
|
+
this.flush();
|
|
110
|
+
}
|
|
111
|
+
private line(stream: string, line: string): void {
|
|
112
|
+
if (this.key) {
|
|
113
|
+
if (PEM_END.test(line)) {
|
|
114
|
+
this.key = null;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const body = PEM_BODY.test(line);
|
|
118
|
+
if (body) {
|
|
119
|
+
this.key.lines += 1;
|
|
120
|
+
this.key.bytes += line.length;
|
|
121
|
+
if (this.key.lines > MAX_KEY_LINES || this.key.bytes > MAX_KEY_BYTES) this.key = null;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (stream === this.key.stream) this.key = null;
|
|
125
|
+
}
|
|
126
|
+
const begin = line.search(PEM_BEGIN);
|
|
127
|
+
if (begin >= 0) {
|
|
128
|
+
if (!PEM_END.test(line.slice(begin))) this.key = { stream, lines: 0, bytes: 0 };
|
|
129
|
+
this.push(`${line.slice(0, begin)}[REDACTED PRIVATE KEY]\n`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.push(line);
|
|
133
|
+
}
|
|
134
|
+
private push(line: string): void {
|
|
135
|
+
this.batch.push(line);
|
|
136
|
+
this.batchBytes += line.length;
|
|
137
|
+
if (this.batchBytes >= BATCH_BYTES) this.flush();
|
|
138
|
+
}
|
|
139
|
+
private flush(): void {
|
|
140
|
+
if (!this.batch.length) return;
|
|
141
|
+
this.sink(redactLog(this.batch.join("")));
|
|
142
|
+
this.batch = [];
|
|
143
|
+
this.batchBytes = 0;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type CheckRun = {
|
|
148
|
+
/** The child's exit code; 128+n when killed by signal n; 127 when it could not start. */
|
|
149
|
+
exitCode: number;
|
|
150
|
+
signal: string | null;
|
|
151
|
+
timedOut: boolean;
|
|
152
|
+
durationMs: number;
|
|
153
|
+
/** Redacted, bounded combined output. */
|
|
154
|
+
log: string;
|
|
155
|
+
/** Total bytes of redacted output (before bounding). */
|
|
156
|
+
outputBytes: number;
|
|
157
|
+
truncated: boolean;
|
|
158
|
+
/** Set when the command could not be started. */
|
|
159
|
+
spawnError: string | null;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export type RunOptions = {
|
|
163
|
+
cwd: string;
|
|
164
|
+
shell?: boolean;
|
|
165
|
+
timeoutMs?: number;
|
|
166
|
+
env?: NodeJS.ProcessEnv;
|
|
167
|
+
/** Receives the child's stdout as it arrives. */
|
|
168
|
+
onStdout?: (text: string) => void;
|
|
169
|
+
onStderr?: (text: string) => void;
|
|
170
|
+
/** For tests: the platform whose spawn rules apply (default: process.platform). */
|
|
171
|
+
platform?: NodeJS.Platform;
|
|
172
|
+
/**
|
|
173
|
+
* POSIX only: argv that runs the command (e.g. a `systemd-run … --` scope
|
|
174
|
+
* prefix). A --shell command then runs as `<wrapper> /bin/sh -c <cmd>`.
|
|
175
|
+
*/
|
|
176
|
+
wrapper?: readonly string[];
|
|
177
|
+
/** Called with the child's pid once it started (on POSIX also its process group id). */
|
|
178
|
+
onSpawn?: (pid: number) => void;
|
|
179
|
+
/** For tests: the spawn function (default: node:child_process spawn). */
|
|
180
|
+
spawn?: typeof spawn;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// cmd.exe metacharacters (the cross-spawn set).
|
|
184
|
+
const CMD_META = /([()\][%!^"`<>&|;, *?])/gu;
|
|
185
|
+
|
|
186
|
+
/** One argument for a `cmd /d /s /c "…"` line; `double` for node_modules/.bin shims, which re-parse. */
|
|
187
|
+
export function cmdArgument(arg: string, double = false): string {
|
|
188
|
+
let out = arg.replace(/(\\*)"/gu, '$1$1\\"').replace(/(\\*)$/u, "$1$1");
|
|
189
|
+
out = `"${out}"`.replace(CMD_META, "^$1");
|
|
190
|
+
return double ? out.replace(CMD_META, "^$1") : out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export type SpawnPlan = {
|
|
194
|
+
file: string;
|
|
195
|
+
args: string[];
|
|
196
|
+
/** Pass the args to CreateProcess verbatim (cmd.exe lines carry their own quoting). */
|
|
197
|
+
verbatim: boolean;
|
|
198
|
+
shell: boolean;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* How to start argv without a shell. On Windows, argv[0] is resolved through
|
|
203
|
+
* PATH and PATHEXT (CreateProcess finds only .exe/.com); a .cmd/.bat shim
|
|
204
|
+
* (npm, pnpm, yarn, node_modules/.bin) runs as `cmd.exe /d /s /c "<line>"`
|
|
205
|
+
* with every argument escaped, so the observed argv (and `configured`) stay
|
|
206
|
+
* the configured ones. Null `isFile` uses the real filesystem.
|
|
207
|
+
*/
|
|
208
|
+
export function planSpawn(
|
|
209
|
+
argv: readonly string[],
|
|
210
|
+
options: {
|
|
211
|
+
platform: NodeJS.Platform;
|
|
212
|
+
env: NodeJS.ProcessEnv;
|
|
213
|
+
cwd: string;
|
|
214
|
+
shell?: boolean;
|
|
215
|
+
isFile?: (file: string) => boolean;
|
|
216
|
+
},
|
|
217
|
+
): SpawnPlan {
|
|
218
|
+
if (options.shell) return { file: argv[0], args: [], verbatim: false, shell: true };
|
|
219
|
+
if (options.platform !== "win32")
|
|
220
|
+
return { file: argv[0], args: argv.slice(1), verbatim: false, shell: false };
|
|
221
|
+
const win = path.win32;
|
|
222
|
+
const isFile =
|
|
223
|
+
options.isFile ??
|
|
224
|
+
((file: string) => {
|
|
225
|
+
try {
|
|
226
|
+
return fs.statSync(file).isFile();
|
|
227
|
+
} catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
// Windows names are case-insensitive; an exact upper-case key wins over a duplicate.
|
|
232
|
+
const env = (name: string): string | undefined =>
|
|
233
|
+
options.env[name] ??
|
|
234
|
+
Object.entries(options.env).find(([key]) => key.toUpperCase() === name)?.[1];
|
|
235
|
+
const exts = (env("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
|
|
236
|
+
const command = argv[0];
|
|
237
|
+
const hasDir = /[\\/]/u.test(command);
|
|
238
|
+
const dirs = hasDir ? [""] : (env("PATH") ?? "").split(";").filter(Boolean);
|
|
239
|
+
const candidates = (base: string) =>
|
|
240
|
+
win.extname(base) ? [base, ...exts.map((ext) => base + ext)] : exts.map((ext) => base + ext);
|
|
241
|
+
let resolved: string | null = null;
|
|
242
|
+
for (const dir of dirs) {
|
|
243
|
+
const base = hasDir ? win.resolve(options.cwd, command) : win.join(dir, command);
|
|
244
|
+
resolved = candidates(base).find(isFile) ?? null;
|
|
245
|
+
if (resolved) break;
|
|
246
|
+
}
|
|
247
|
+
if (!resolved) return { file: command, args: argv.slice(1), verbatim: false, shell: false };
|
|
248
|
+
if (!/\.(?:cmd|bat)$/iu.test(resolved))
|
|
249
|
+
return { file: resolved, args: argv.slice(1), verbatim: false, shell: false };
|
|
250
|
+
const double = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/iu.test(resolved);
|
|
251
|
+
const line = [
|
|
252
|
+
win.normalize(resolved).replace(CMD_META, "^$1"),
|
|
253
|
+
...argv.slice(1).map((arg) => cmdArgument(arg, double)),
|
|
254
|
+
].join(" ");
|
|
255
|
+
return {
|
|
256
|
+
file: env("COMSPEC") ?? "cmd.exe",
|
|
257
|
+
args: ["/d", "/s", "/c", `"${line}"`],
|
|
258
|
+
verbatim: true,
|
|
259
|
+
shell: false,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Is `pid` still a process (ESRCH: gone; EPERM: exists but not ours)? */
|
|
264
|
+
const alive = (pid: number): boolean => {
|
|
265
|
+
try {
|
|
266
|
+
process.kill(pid, 0);
|
|
267
|
+
return true;
|
|
268
|
+
} catch (error) {
|
|
269
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/** After the child exits, how long its pipes may stay open (held by leftovers) before they are cut. */
|
|
274
|
+
const DRAIN_MS = 250;
|
|
275
|
+
/** Once both pipes closed without an `exit` event, how often the child's own status is read. */
|
|
276
|
+
const STATUS_POLL_MS = 100;
|
|
277
|
+
const FORWARDED_SIGNALS: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Run one command, streaming its output to the callbacks while a redacted,
|
|
281
|
+
* bounded copy is captured. With `shell`, argv must be a single command
|
|
282
|
+
* string. On POSIX the child leads its own process group (stdin is not
|
|
283
|
+
* inherited), so a timeout, or the exit of the command, kills every process
|
|
284
|
+
* it started; Ctrl-C and SIGTERM sent to workit are forwarded to the group.
|
|
285
|
+
* On Windows the tree is killed with `taskkill /T /F`. The run settles on
|
|
286
|
+
* the child's exit plus a short drain, so a leftover holding the pipes open
|
|
287
|
+
* cannot hang it.
|
|
288
|
+
*/
|
|
289
|
+
export function runCheckCommand(argv: readonly string[], options: RunOptions): Promise<CheckRun> {
|
|
290
|
+
const started = Date.now();
|
|
291
|
+
const platform = options.platform ?? process.platform;
|
|
292
|
+
const windows = process.platform === "win32";
|
|
293
|
+
const buffer = new TailBuffer(MAX_LOG_BYTES);
|
|
294
|
+
const redactor = new StreamRedactor((text) => buffer.push(text));
|
|
295
|
+
const env = options.env ?? process.env;
|
|
296
|
+
const wrapped = Boolean(options.wrapper?.length) && platform !== "win32";
|
|
297
|
+
const command = wrapped
|
|
298
|
+
? [...options.wrapper!, ...(options.shell ? ["/bin/sh", "-c", argv[0]] : argv)]
|
|
299
|
+
: argv;
|
|
300
|
+
const shell = wrapped ? false : options.shell;
|
|
301
|
+
const plan = planSpawn(command, { platform, env, cwd: options.cwd, shell });
|
|
302
|
+
return new Promise((resolve) => {
|
|
303
|
+
let settled = false;
|
|
304
|
+
let timedOut = false;
|
|
305
|
+
let drain: NodeJS.Timeout | null = null;
|
|
306
|
+
let child: ReturnType<typeof spawn>;
|
|
307
|
+
let timer: NodeJS.Timeout | null = null;
|
|
308
|
+
let poll: NodeJS.Timeout | null = null;
|
|
309
|
+
let exited = false;
|
|
310
|
+
const killTree = () => {
|
|
311
|
+
if (!child?.pid) return;
|
|
312
|
+
if (windows)
|
|
313
|
+
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
314
|
+
stdio: "ignore",
|
|
315
|
+
windowsHide: true,
|
|
316
|
+
});
|
|
317
|
+
else
|
|
318
|
+
try {
|
|
319
|
+
process.kill(-child.pid, "SIGKILL");
|
|
320
|
+
} catch {
|
|
321
|
+
// The group is already gone.
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
const forwarders = new Map<NodeJS.Signals, () => void>();
|
|
325
|
+
const finish = (code: number | null, signal: NodeJS.Signals | null, spawnError?: string) => {
|
|
326
|
+
if (settled) return;
|
|
327
|
+
settled = true;
|
|
328
|
+
if (timer) clearTimeout(timer);
|
|
329
|
+
if (drain) clearTimeout(drain);
|
|
330
|
+
if (poll) clearInterval(poll);
|
|
331
|
+
for (const [name, handler] of forwarders) process.off(name, handler);
|
|
332
|
+
child?.stdout?.destroy();
|
|
333
|
+
child?.stderr?.destroy();
|
|
334
|
+
if (spawnError) redactor.write("workit", `workit: cannot start ${argv[0]}: ${spawnError}\n`);
|
|
335
|
+
redactor.end();
|
|
336
|
+
resolve({
|
|
337
|
+
exitCode: spawnError
|
|
338
|
+
? 127
|
|
339
|
+
: code !== null
|
|
340
|
+
? code
|
|
341
|
+
: 128 + (signal ? (os.constants.signals[signal] ?? 1) : 1),
|
|
342
|
+
signal: signal ?? null,
|
|
343
|
+
timedOut,
|
|
344
|
+
durationMs: Date.now() - started,
|
|
345
|
+
log: buffer.text(),
|
|
346
|
+
outputBytes: buffer.total,
|
|
347
|
+
truncated: buffer.total > MAX_LOG_BYTES,
|
|
348
|
+
spawnError: spawnError ?? null,
|
|
349
|
+
});
|
|
350
|
+
};
|
|
351
|
+
const spawnOptions = {
|
|
352
|
+
cwd: options.cwd,
|
|
353
|
+
env,
|
|
354
|
+
stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"],
|
|
355
|
+
windowsHide: true,
|
|
356
|
+
detached: !windows,
|
|
357
|
+
windowsVerbatimArguments: plan.verbatim,
|
|
358
|
+
shell: plan.shell,
|
|
359
|
+
};
|
|
360
|
+
try {
|
|
361
|
+
child = (options.spawn ?? spawn)(plan.file, plan.args, spawnOptions);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
finish(null, null, (error as NodeJS.ErrnoException).code ?? (error as Error).message);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (child.pid) options.onSpawn?.(child.pid);
|
|
367
|
+
if (!windows)
|
|
368
|
+
for (const name of FORWARDED_SIGNALS) {
|
|
369
|
+
const handler = () => {
|
|
370
|
+
if (child.pid)
|
|
371
|
+
try {
|
|
372
|
+
process.kill(-child.pid, name);
|
|
373
|
+
} catch {
|
|
374
|
+
// Already gone.
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
forwarders.set(name, handler);
|
|
378
|
+
process.on(name, handler);
|
|
379
|
+
}
|
|
380
|
+
if (options.timeoutMs && options.timeoutMs > 0)
|
|
381
|
+
timer = setTimeout(() => {
|
|
382
|
+
timedOut = true;
|
|
383
|
+
killTree();
|
|
384
|
+
}, options.timeoutMs);
|
|
385
|
+
const forward = (
|
|
386
|
+
name: string,
|
|
387
|
+
stream: NodeJS.ReadableStream | null,
|
|
388
|
+
sink?: (text: string) => void,
|
|
389
|
+
) => {
|
|
390
|
+
if (!stream) return;
|
|
391
|
+
const decoder = new StringDecoder("utf8");
|
|
392
|
+
stream.on("data", (chunk: Buffer) => {
|
|
393
|
+
const text = decoder.write(chunk);
|
|
394
|
+
redactor.write(name, text);
|
|
395
|
+
if (sink) sink(text);
|
|
396
|
+
});
|
|
397
|
+
stream.on("end", () => {
|
|
398
|
+
const rest = decoder.end();
|
|
399
|
+
if (rest) {
|
|
400
|
+
redactor.write(name, rest);
|
|
401
|
+
if (sink) sink(rest);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
stream.on("error", () => {
|
|
405
|
+
// A destroyed pipe after the drain: the captured output stands.
|
|
406
|
+
});
|
|
407
|
+
};
|
|
408
|
+
forward("stdout", child.stdout, options.onStdout);
|
|
409
|
+
forward("stderr", child.stderr, options.onStderr);
|
|
410
|
+
child.on("error", (error: NodeJS.ErrnoException) =>
|
|
411
|
+
finish(null, null, error.code ?? error.message),
|
|
412
|
+
);
|
|
413
|
+
// The run must not hang on a lost `exit` event (#730: a 30 s stall on
|
|
414
|
+
// macOS CI that `exit` and `close` never ended): once both pipes closed,
|
|
415
|
+
// read the child's own status; a child that is gone without one settles
|
|
416
|
+
// as a failure that says so. A live child keeps waiting (the timeout and
|
|
417
|
+
// the normal events still apply).
|
|
418
|
+
let open = (child.stdout ? 1 : 0) + (child.stderr ? 1 : 0);
|
|
419
|
+
const pipeClosed = () => {
|
|
420
|
+
open -= 1;
|
|
421
|
+
if (open > 0 || exited || settled || poll) return;
|
|
422
|
+
poll = setInterval(() => {
|
|
423
|
+
if (exited || settled) return;
|
|
424
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
425
|
+
finish(child.exitCode, child.signalCode);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (child.pid && !alive(child.pid)) {
|
|
429
|
+
redactor.write("workit", "workit: the command ended without reporting its exit status\n");
|
|
430
|
+
finish(1, null);
|
|
431
|
+
}
|
|
432
|
+
}, STATUS_POLL_MS);
|
|
433
|
+
};
|
|
434
|
+
child.stdout?.on("close", pipeClosed);
|
|
435
|
+
child.stderr?.on("close", pipeClosed);
|
|
436
|
+
child.on("exit", (code, signal) => {
|
|
437
|
+
exited = true;
|
|
438
|
+
// Leftovers of the command die with it; then give the pipes a moment.
|
|
439
|
+
if (!windows) killTree();
|
|
440
|
+
drain = setTimeout(() => finish(code, signal), DRAIN_MS);
|
|
441
|
+
});
|
|
442
|
+
child.on("close", (code, signal) => finish(code, signal));
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Content address of a stored log: `sha256:<hex>`. */
|
|
447
|
+
export const logDigest = (log: string): string =>
|
|
448
|
+
`sha256:${createHash("sha256").update(log).digest("hex")}`;
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Store a redacted log under `<storeRoot>/blobs/logs/<hex>.log` (content
|
|
452
|
+
* addressed, so concurrent writers of the same log agree). Returns the path
|
|
453
|
+
* relative to the store root, or null when the write fails.
|
|
454
|
+
*/
|
|
455
|
+
export function storeLog(storeRoot: string, log: string): { digest: string; ref: string } | null {
|
|
456
|
+
const digest = logDigest(log);
|
|
457
|
+
const ref = `blobs/logs/${digest.slice("sha256:".length)}.log`;
|
|
458
|
+
const file = path.join(storeRoot, ref);
|
|
459
|
+
try {
|
|
460
|
+
if (!fs.existsSync(file)) {
|
|
461
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
462
|
+
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
463
|
+
fs.writeFileSync(temp, log, { mode: 0o600 });
|
|
464
|
+
fs.renameSync(temp, file);
|
|
465
|
+
}
|
|
466
|
+
return { digest, ref };
|
|
467
|
+
} catch {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export type LogPruneReport = { removed: number; removedBytes: number; kept: number };
|
|
473
|
+
|
|
474
|
+
/** Check-log retention for `workit gc`: newest 200, at most 30 days old, at most 256 MB. */
|
|
475
|
+
export const LOG_RETENTION = {
|
|
476
|
+
maxCount: 200,
|
|
477
|
+
maxAgeMs: 30 * 86_400_000,
|
|
478
|
+
maxBytes: 256 * 1024 * 1024,
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Prune `<storeRoot>/blobs/logs`: keep the newest logs within the count, age
|
|
483
|
+
* and size caps, and drop leftover temp files. Evidence keeps its logDigest
|
|
484
|
+
* when its blob is pruned. `dryRun` only reports.
|
|
485
|
+
*/
|
|
486
|
+
export function pruneCheckLogs(
|
|
487
|
+
storeRoot: string,
|
|
488
|
+
options: { dryRun?: boolean; now?: number; retention?: Partial<typeof LOG_RETENTION> } = {},
|
|
489
|
+
): LogPruneReport {
|
|
490
|
+
const limits = { ...LOG_RETENTION, ...options.retention };
|
|
491
|
+
const now = options.now ?? Date.now();
|
|
492
|
+
const dir = path.join(storeRoot, "blobs", "logs");
|
|
493
|
+
const report: LogPruneReport = { removed: 0, removedBytes: 0, kept: 0 };
|
|
494
|
+
let names: string[];
|
|
495
|
+
try {
|
|
496
|
+
names = fs.readdirSync(dir);
|
|
497
|
+
} catch {
|
|
498
|
+
return report;
|
|
499
|
+
}
|
|
500
|
+
const files = names
|
|
501
|
+
.flatMap((name) => {
|
|
502
|
+
try {
|
|
503
|
+
const stat = fs.statSync(path.join(dir, name));
|
|
504
|
+
return stat.isFile() ? [{ name, size: stat.size, mtimeMs: stat.mtimeMs }] : [];
|
|
505
|
+
} catch {
|
|
506
|
+
return [];
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
.toSorted((a, b) => b.mtimeMs - a.mtimeMs);
|
|
510
|
+
let keptBytes = 0;
|
|
511
|
+
for (const file of files) {
|
|
512
|
+
const temp = file.name.endsWith(".tmp");
|
|
513
|
+
const keep =
|
|
514
|
+
!temp &&
|
|
515
|
+
report.kept < limits.maxCount &&
|
|
516
|
+
now - file.mtimeMs <= limits.maxAgeMs &&
|
|
517
|
+
keptBytes + file.size <= limits.maxBytes;
|
|
518
|
+
if (keep) {
|
|
519
|
+
report.kept += 1;
|
|
520
|
+
keptBytes += file.size;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (temp && now - file.mtimeMs < 3_600_000) continue;
|
|
524
|
+
if (!options.dryRun)
|
|
525
|
+
try {
|
|
526
|
+
fs.rmSync(path.join(dir, file.name), { force: true });
|
|
527
|
+
} catch {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
report.removed += 1;
|
|
531
|
+
report.removedBytes += file.size;
|
|
532
|
+
}
|
|
533
|
+
return report;
|
|
534
|
+
}
|
package/src/ci-waiter.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// A live `workit ci wait` (retro 2026-11-07 R4, D4): the wait claims a marker
|
|
2
|
+
// keyed by repository and PR, holding its pid, process start, the head it
|
|
3
|
+
// waits on and a wall-clock deadline. The Stop hook reads it so a lead whose
|
|
4
|
+
// wait was moved to the background is not told to start another, and a
|
|
5
|
+
// second `ci wait` for the same PR and head reports the first instead of
|
|
6
|
+
// polling again. A marker whose process died, whose pid was reused, or whose
|
|
7
|
+
// deadline passed is not live.
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { gitCommonDir } from "./git/rev";
|
|
11
|
+
import { localLockHost, processStartOf } from "./core/store-lock";
|
|
12
|
+
|
|
13
|
+
export type CiWaiter = {
|
|
14
|
+
pr: number;
|
|
15
|
+
/** The head the wait is on: `--head`, else the PR head of its first poll; null before it. */
|
|
16
|
+
head: string | null;
|
|
17
|
+
pid: number;
|
|
18
|
+
/** The pid's start, so a reused pid is not mistaken for the wait. */
|
|
19
|
+
start: string | null;
|
|
20
|
+
/** The pid space the pid belongs to. */
|
|
21
|
+
host: string;
|
|
22
|
+
startedAt: number;
|
|
23
|
+
/** Wall-clock ms past which the wait has ended, whatever its pid. */
|
|
24
|
+
deadline: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Slack past the wait's timeout for the final status build. */
|
|
28
|
+
export const CI_WAITER_SLACK_MS = 60_000;
|
|
29
|
+
|
|
30
|
+
const waiterFile = (cwd: string, pr: number): string | null => {
|
|
31
|
+
const common = gitCommonDir(cwd);
|
|
32
|
+
return common ? path.join(common, "workit", "ci-waits", `pr-${pr}.json`) : null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const pidAlive = (pid: number): boolean => {
|
|
36
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
37
|
+
try {
|
|
38
|
+
process.kill(pid, 0);
|
|
39
|
+
return true;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const parse = (raw: string): CiWaiter | null => {
|
|
46
|
+
try {
|
|
47
|
+
const value = JSON.parse(raw) as Partial<CiWaiter>;
|
|
48
|
+
if (
|
|
49
|
+
typeof value.pr !== "number" ||
|
|
50
|
+
typeof value.pid !== "number" ||
|
|
51
|
+
typeof value.deadline !== "number" ||
|
|
52
|
+
typeof value.host !== "string" ||
|
|
53
|
+
typeof value.startedAt !== "number"
|
|
54
|
+
)
|
|
55
|
+
return null;
|
|
56
|
+
return {
|
|
57
|
+
pr: value.pr,
|
|
58
|
+
head: typeof value.head === "string" ? value.head : null,
|
|
59
|
+
pid: value.pid,
|
|
60
|
+
start: typeof value.start === "string" ? value.start : null,
|
|
61
|
+
host: value.host,
|
|
62
|
+
startedAt: value.startedAt,
|
|
63
|
+
deadline: value.deadline,
|
|
64
|
+
};
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Is the marker's wait still running: same pid space, pid alive with the same start, before its deadline? */
|
|
71
|
+
export const waiterLive = (waiter: CiWaiter, nowMs: number = Date.now()): boolean => {
|
|
72
|
+
if (nowMs >= waiter.deadline || waiter.host !== localLockHost()) return false;
|
|
73
|
+
if (!pidAlive(waiter.pid)) return false;
|
|
74
|
+
const start = processStartOf(waiter.pid);
|
|
75
|
+
return waiter.start === null || start === null || start === waiter.start;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Another process's live wait on `pr`, or null. With `head`, only a wait on
|
|
80
|
+
* that head (or one that has not polled yet) counts.
|
|
81
|
+
*/
|
|
82
|
+
export const liveCiWaiter = (
|
|
83
|
+
cwd: string,
|
|
84
|
+
pr: number,
|
|
85
|
+
head: string | null = null,
|
|
86
|
+
nowMs: number = Date.now(),
|
|
87
|
+
): CiWaiter | null => {
|
|
88
|
+
try {
|
|
89
|
+
const file = waiterFile(cwd, pr);
|
|
90
|
+
if (!file) return null;
|
|
91
|
+
const waiter = parse(fs.readFileSync(file, "utf8"));
|
|
92
|
+
if (!waiter || waiter.pr !== pr || waiter.pid === process.pid) return null;
|
|
93
|
+
if (head && waiter.head && !sameSha(waiter.head, head)) return null;
|
|
94
|
+
return waiterLive(waiter, nowMs) ? waiter : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const sameSha = (left: string, right: string): boolean =>
|
|
101
|
+
left.length >= right.length ? left.startsWith(right) : right.startsWith(left);
|
|
102
|
+
|
|
103
|
+
const write = (file: string, waiter: CiWaiter): void => {
|
|
104
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
105
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
106
|
+
fs.writeFileSync(temp, `${JSON.stringify(waiter)}\n`);
|
|
107
|
+
fs.renameSync(temp, file);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export type CiWaiterClaim = {
|
|
111
|
+
/** Record the head the wait is on once it is known. */
|
|
112
|
+
setHead: (head: string) => void;
|
|
113
|
+
/** Remove the marker if it is still this process's. */
|
|
114
|
+
release: () => void;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Claim the marker for this process's wait on `pr`. Never throws: a failed write leaves no marker. */
|
|
118
|
+
export const claimCiWaiter = (
|
|
119
|
+
cwd: string,
|
|
120
|
+
pr: number,
|
|
121
|
+
options: { head: string | null; timeoutMs: number; nowMs?: number },
|
|
122
|
+
): CiWaiterClaim => {
|
|
123
|
+
const file = waiterFile(cwd, pr);
|
|
124
|
+
const startedAt = options.nowMs ?? Date.now();
|
|
125
|
+
const waiter: CiWaiter = {
|
|
126
|
+
pr,
|
|
127
|
+
head: options.head,
|
|
128
|
+
pid: process.pid,
|
|
129
|
+
start: processStartOf(process.pid),
|
|
130
|
+
host: localLockHost(),
|
|
131
|
+
startedAt,
|
|
132
|
+
deadline: startedAt + options.timeoutMs + CI_WAITER_SLACK_MS,
|
|
133
|
+
};
|
|
134
|
+
const ours = (): boolean => {
|
|
135
|
+
try {
|
|
136
|
+
return file !== null && parse(fs.readFileSync(file, "utf8"))?.pid === process.pid;
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
try {
|
|
142
|
+
if (file) write(file, waiter);
|
|
143
|
+
} catch {}
|
|
144
|
+
return {
|
|
145
|
+
setHead: (head) => {
|
|
146
|
+
if (waiter.head === head || !file || !ours()) return;
|
|
147
|
+
waiter.head = head;
|
|
148
|
+
try {
|
|
149
|
+
write(file, waiter);
|
|
150
|
+
} catch {}
|
|
151
|
+
},
|
|
152
|
+
release: () => {
|
|
153
|
+
if (!file || !ours()) return;
|
|
154
|
+
try {
|
|
155
|
+
fs.rmSync(file, { force: true });
|
|
156
|
+
} catch {}
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
};
|