@open-agent-toolkit/cli 0.3.16 → 0.3.17
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/assets/agents/oat-phase-implementer.md +26 -6
- package/assets/agents/oat-reviewer.md +51 -1
- package/assets/bundle-metadata.json +1 -1
- package/assets/docs/contributing/documentation.md +4 -0
- package/assets/docs/contributing/skills.md +3 -1
- package/assets/docs/getting-started/bootstrap.md +1 -1
- package/assets/docs/getting-started/index.md +0 -4
- package/assets/docs/getting-started/tool-packs.md +0 -7
- package/assets/docs/reference/cli-reference.md +42 -1
- package/assets/docs/reference/config-and-local-state.md +11 -2
- package/assets/docs/reference/project-artifacts.md +43 -8
- package/assets/docs/skills/repo-improve.md +9 -7
- package/assets/docs/workflows/advanced/autonomy.md +40 -0
- package/assets/docs/workflows/backlog-and-planning/backlog-lifecycle.md +25 -24
- package/assets/docs/workflows/backlog-and-planning/remote-project-management.md +0 -7
- package/assets/docs/workflows/ideas/lifecycle.md +2 -7
- package/assets/docs/workflows/projects/reviews/review-flavors.md +8 -0
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/oat-agent-instructions-apply/SKILL.md +14 -4
- package/assets/skills/oat-brainstorm/SKILL.md +17 -13
- package/assets/skills/oat-docs-apply/SKILL.md +13 -3
- package/assets/skills/oat-doctor/SKILL.md +11 -1
- package/assets/skills/oat-explainer-kit/SKILL.md +1 -1
- package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +6 -3
- package/assets/skills/oat-pjm-review-backlog/SKILL.md +2 -2
- package/assets/skills/oat-pjm-update-repo-reference/SKILL.md +28 -3
- package/assets/skills/oat-project-autonomous/SKILL.md +60 -5
- package/assets/skills/oat-project-autonomous/references/gate-inventory.md +44 -6
- package/assets/skills/oat-project-capture/SKILL.md +7 -3
- package/assets/skills/oat-project-complete/SKILL.md +39 -34
- package/assets/skills/oat-project-complete/scripts/execute-synced-archive-entry.mjs +106 -28
- package/assets/skills/oat-project-complete/scripts/parse-synced-archive-resume-fields.mjs +30 -3
- package/assets/skills/oat-project-design/SKILL.md +12 -9
- package/assets/skills/oat-project-discover/SKILL.md +8 -4
- package/assets/skills/oat-project-document/SKILL.md +14 -5
- package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +44 -6
- package/assets/skills/oat-project-implement/SKILL.md +1 -1
- package/assets/skills/oat-project-implement/references/completion-and-closeout.md +6 -6
- package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +44 -6
- package/assets/skills/oat-project-implement/references/phase-execution.md +20 -6
- package/assets/skills/oat-project-import-plan/SKILL.md +20 -6
- package/assets/skills/oat-project-lite/SKILL.md +7 -5
- package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +44 -6
- package/assets/skills/oat-project-new/SKILL.md +6 -2
- package/assets/skills/oat-project-plan/SKILL.md +14 -6
- package/assets/skills/oat-project-pr-final/SKILL.md +1 -1
- package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +44 -6
- package/assets/skills/oat-project-progress/SKILL.md +13 -1
- package/assets/skills/oat-project-promote-spec-driven/SKILL.md +7 -3
- package/assets/skills/oat-project-quick-start/SKILL.md +17 -12
- package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +44 -6
- package/assets/skills/oat-project-reconcile/SKILL.md +8 -4
- package/assets/skills/oat-project-retro/SKILL.md +1 -1
- package/assets/skills/oat-project-retro/references/apply-procedure.md +6 -6
- package/assets/skills/oat-project-retro-file/SKILL.md +7 -3
- package/assets/skills/oat-project-review-provide/SKILL.md +23 -4
- package/assets/skills/oat-project-review-provide-remote/SKILL.md +19 -1
- package/assets/skills/oat-project-review-receive/SKILL.md +37 -12
- package/assets/skills/oat-project-review-receive-remote/SKILL.md +7 -3
- package/assets/skills/oat-project-revise/SKILL.md +13 -5
- package/assets/skills/oat-project-spec/SKILL.md +8 -4
- package/assets/skills/oat-project-summary/SKILL.md +11 -7
- package/assets/skills/oat-repo-knowledge-index/SKILL.md +54 -20
- package/assets/skills/oat-repo-knowledge-index/scripts/refresh-owned.mjs +118 -0
- package/assets/skills/oat-review-provide/SKILL.md +7 -3
- package/assets/skills/oat-review-receive-remote/SKILL.md +14 -4
- package/assets/skills/oat-wave-execute/SKILL.md +24 -11
- package/assets/skills/oat-wave-execute/assets/wrapper-plan-template.md +16 -2
- package/assets/skills/oat-worktree-bootstrap-auto/SKILL.md +45 -17
- package/assets/templates/pjm-agents.md +38 -8
- package/assets/templates/repo-agents.md +5 -0
- package/assets/templates/repo-readme.md +5 -0
- package/dist/commands/backlog/archive.d.ts +3 -0
- package/dist/commands/backlog/archive.d.ts.map +1 -1
- package/dist/commands/backlog/archive.js +63 -26
- package/dist/commands/backlog/index.d.ts.map +1 -1
- package/dist/commands/backlog/index.js +3 -0
- package/dist/commands/backlog/rewrite-references.d.ts +5 -0
- package/dist/commands/backlog/rewrite-references.d.ts.map +1 -1
- package/dist/commands/backlog/rewrite-references.js +39 -1
- package/dist/commands/init/index.d.ts.map +1 -1
- package/dist/commands/init/index.js +1 -0
- package/dist/commands/internal/commit-paths.d.ts +3 -0
- package/dist/commands/internal/commit-paths.d.ts.map +1 -0
- package/dist/commands/internal/commit-paths.js +31 -0
- package/dist/commands/internal/index.d.ts.map +1 -1
- package/dist/commands/internal/index.js +2 -0
- package/dist/commands/pjm/init.d.ts.map +1 -1
- package/dist/commands/pjm/init.js +37 -8
- package/dist/commands/project/archive/archive-utils.d.ts +6 -3
- package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
- package/dist/commands/project/archive/archive-utils.js +510 -104
- package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
- package/dist/commands/project/archive/push-runner.js +1 -0
- package/dist/commands/project/log/append.d.ts +2 -3
- package/dist/commands/project/log/append.d.ts.map +1 -1
- package/dist/commands/project/log/append.js +55 -101
- package/dist/commands/project/migrate/index.d.ts +1 -2
- package/dist/commands/project/migrate/index.d.ts.map +1 -1
- package/dist/commands/project/migrate/index.js +8 -2
- package/dist/commands/project/new/scaffold.d.ts.map +1 -1
- package/dist/commands/project/new/scaffold.js +4 -4
- package/dist/commands/project/promote/promote.d.ts +8 -0
- package/dist/commands/project/promote/promote.d.ts.map +1 -1
- package/dist/commands/project/promote/promote.js +37 -6
- package/dist/commands/project/status.d.ts.map +1 -1
- package/dist/commands/project/status.js +3 -0
- package/dist/commands/project/sync/ref-sync.d.ts +12 -1
- package/dist/commands/project/sync/ref-sync.d.ts.map +1 -1
- package/dist/commands/project/sync/ref-sync.js +347 -23
- package/dist/commands/shared/exact-path-commit.d.ts +32 -0
- package/dist/commands/shared/exact-path-commit.d.ts.map +1 -0
- package/dist/commands/shared/exact-path-commit.js +691 -0
- package/dist/validation/skills.d.ts.map +1 -1
- package/dist/validation/skills.js +138 -11
- package/package.json +3 -2
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import { execFile, spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import * as nodeFs from 'node:fs';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { access, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, writeFile, } from 'node:fs/promises';
|
|
6
|
+
import * as nodePath from 'node:path';
|
|
7
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
const defaults = {
|
|
11
|
+
attempts: 3,
|
|
12
|
+
retryDelaysMs: [100, 300],
|
|
13
|
+
sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
|
|
14
|
+
};
|
|
15
|
+
function digest(value) {
|
|
16
|
+
return createHash('sha256').update(value).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
function environment(index) {
|
|
19
|
+
const env = {
|
|
20
|
+
...process.env,
|
|
21
|
+
LANG: 'C',
|
|
22
|
+
LC_ALL: 'C',
|
|
23
|
+
LANGUAGE: 'C',
|
|
24
|
+
};
|
|
25
|
+
for (const key of [
|
|
26
|
+
'GIT_DIR',
|
|
27
|
+
'GIT_WORK_TREE',
|
|
28
|
+
'GIT_INDEX_FILE',
|
|
29
|
+
'GIT_COMMON_DIR',
|
|
30
|
+
'GIT_OBJECT_DIRECTORY',
|
|
31
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
32
|
+
'GIT_NAMESPACE',
|
|
33
|
+
])
|
|
34
|
+
delete env[key];
|
|
35
|
+
if (index)
|
|
36
|
+
env.GIT_INDEX_FILE = index;
|
|
37
|
+
return env;
|
|
38
|
+
}
|
|
39
|
+
async function git(root, args, index) {
|
|
40
|
+
const { stdout } = await exec('git', ['--literal-pathspecs', ...args], {
|
|
41
|
+
cwd: root,
|
|
42
|
+
env: environment(index),
|
|
43
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
44
|
+
});
|
|
45
|
+
return stdout.trimEnd();
|
|
46
|
+
}
|
|
47
|
+
async function bytes(path) {
|
|
48
|
+
try {
|
|
49
|
+
return await readFile(path);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code === 'ENOENT')
|
|
53
|
+
return undefined;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Serialized into the hook companion as well: only Git-emitted directory
|
|
58
|
+
// boundaries recurse, and links remain literal. Ignored files retain Git's
|
|
59
|
+
// ordinary inventory semantics; no nested metadata is refreshed or written.
|
|
60
|
+
function preservationIdentity(full, boundary, dependencies) {
|
|
61
|
+
const { fs: fileSystem, path: paths, spawnSync: runCommand, createHash: hashFactory, } = dependencies;
|
|
62
|
+
const hash = (value) => hashFactory('sha256').update(value).digest('hex');
|
|
63
|
+
let info;
|
|
64
|
+
try {
|
|
65
|
+
info = fileSystem.lstatSync(full);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error.code === 'ENOENT')
|
|
69
|
+
return 'absent';
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
if (info.isSymbolicLink())
|
|
73
|
+
return `symlink:${fileSystem.readlinkSync(full)}`;
|
|
74
|
+
const physical = fileSystem.realpathSync(full);
|
|
75
|
+
const confined = paths.relative(boundary, physical);
|
|
76
|
+
if (confined === '..' ||
|
|
77
|
+
confined.startsWith('../') ||
|
|
78
|
+
paths.isAbsolute(confined))
|
|
79
|
+
throw new Error(`Unowned inventory escapes repository: ${full}`);
|
|
80
|
+
if (!info.isDirectory())
|
|
81
|
+
return `${info.mode}:${hash(fileSystem.readFileSync(full))}`;
|
|
82
|
+
const env = { ...process.env, GIT_OPTIONAL_LOCKS: '0' };
|
|
83
|
+
for (const key of [
|
|
84
|
+
'GIT_DIR',
|
|
85
|
+
'GIT_WORK_TREE',
|
|
86
|
+
'GIT_INDEX_FILE',
|
|
87
|
+
'GIT_COMMON_DIR',
|
|
88
|
+
'GIT_OBJECT_DIRECTORY',
|
|
89
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
90
|
+
'GIT_NAMESPACE',
|
|
91
|
+
])
|
|
92
|
+
delete env[key];
|
|
93
|
+
const run = (args) => runCommand('git', ['--literal-pathspecs', ...args], {
|
|
94
|
+
cwd: full,
|
|
95
|
+
env,
|
|
96
|
+
encoding: 'utf8',
|
|
97
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
98
|
+
});
|
|
99
|
+
const top = run(['rev-parse', '--show-toplevel']);
|
|
100
|
+
if (top.status !== 0 ||
|
|
101
|
+
fileSystem.realpathSync(top.stdout.trimEnd()) !== physical) {
|
|
102
|
+
if (fileSystem.readdirSync(full).length === 0)
|
|
103
|
+
return `${info.mode}:empty-directory`;
|
|
104
|
+
throw new Error(`Directory inventory is not a nested Git boundary: ${full}`);
|
|
105
|
+
}
|
|
106
|
+
const inventory = run([
|
|
107
|
+
'ls-files',
|
|
108
|
+
'-z',
|
|
109
|
+
'--cached',
|
|
110
|
+
'--others',
|
|
111
|
+
'--exclude-standard',
|
|
112
|
+
]);
|
|
113
|
+
const index = run(['rev-parse', '--git-path', 'index']);
|
|
114
|
+
const head = run(['rev-parse', '--verify', 'HEAD']);
|
|
115
|
+
if (inventory.status !== 0 || index.status !== 0)
|
|
116
|
+
throw new Error(`Cannot inspect nested Git inventory: ${full}`);
|
|
117
|
+
let indexBytes;
|
|
118
|
+
try {
|
|
119
|
+
indexBytes = fileSystem.readFileSync(paths.resolve(full, index.stdout.trimEnd()));
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (error.code !== 'ENOENT')
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
const entries = [...new Set(inventory.stdout.split('\0').filter(Boolean))]
|
|
126
|
+
.sort()
|
|
127
|
+
.map((entry) => {
|
|
128
|
+
const child = paths.resolve(full, entry);
|
|
129
|
+
const childRelative = paths.relative(full, child);
|
|
130
|
+
if (!childRelative ||
|
|
131
|
+
childRelative === '..' ||
|
|
132
|
+
childRelative.startsWith('../') ||
|
|
133
|
+
paths.isAbsolute(childRelative))
|
|
134
|
+
throw new Error(`Invalid nested Git inventory path: ${entry}`);
|
|
135
|
+
let parent = paths.dirname(child);
|
|
136
|
+
while (!fileSystem.existsSync(parent))
|
|
137
|
+
parent = paths.dirname(parent);
|
|
138
|
+
const ancestor = paths.relative(boundary, fileSystem.realpathSync(parent));
|
|
139
|
+
if (ancestor === '..' ||
|
|
140
|
+
ancestor.startsWith('../') ||
|
|
141
|
+
paths.isAbsolute(ancestor))
|
|
142
|
+
throw new Error(`Nested inventory traverses an external link: ${entry}`);
|
|
143
|
+
return [entry, preservationIdentity(child, boundary, dependencies)];
|
|
144
|
+
});
|
|
145
|
+
return `${info.mode}:git:${hash(JSON.stringify([head.status === 0 ? head.stdout.trimEnd() : '', indexBytes ? hash(indexBytes) : 'absent', entries]))}`;
|
|
146
|
+
}
|
|
147
|
+
function fileIdentity(full, boundary) {
|
|
148
|
+
return preservationIdentity(full, boundary, {
|
|
149
|
+
fs: nodeFs,
|
|
150
|
+
path: nodePath,
|
|
151
|
+
spawnSync,
|
|
152
|
+
createHash,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
async function writeReceipt(path, receipt) {
|
|
156
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
157
|
+
await writeFile(temporary, JSON.stringify(receipt));
|
|
158
|
+
await rename(temporary, path);
|
|
159
|
+
}
|
|
160
|
+
function detail(error) {
|
|
161
|
+
const failure = error;
|
|
162
|
+
return failure.stderr?.trim() || failure.message || String(error);
|
|
163
|
+
}
|
|
164
|
+
async function assertNoInProgressGitOperation(root) {
|
|
165
|
+
for (const state of [
|
|
166
|
+
'MERGE_HEAD',
|
|
167
|
+
'CHERRY_PICK_HEAD',
|
|
168
|
+
'REVERT_HEAD',
|
|
169
|
+
'REBASE_HEAD',
|
|
170
|
+
'rebase-apply',
|
|
171
|
+
'rebase-merge',
|
|
172
|
+
'sequencer',
|
|
173
|
+
]) {
|
|
174
|
+
// Resolve through Git: linked worktrees keep these markers in their own
|
|
175
|
+
// metadata directory rather than the checkout's .git file or common dir.
|
|
176
|
+
const path = resolve(root, await git(root, ['rev-parse', '--git-path', state]));
|
|
177
|
+
const exists = await lstat(path).then(() => true, (error) => {
|
|
178
|
+
if (error.code === 'ENOENT')
|
|
179
|
+
return false;
|
|
180
|
+
throw error;
|
|
181
|
+
});
|
|
182
|
+
if (exists)
|
|
183
|
+
throw new Error(`Repository has an in-progress Git operation (${state}); resolve or abort it before retrying. No commit was launched.`);
|
|
184
|
+
}
|
|
185
|
+
if (await git(root, ['ls-files', '--unmerged']))
|
|
186
|
+
throw new Error('Repository has unmerged index entries; resolve them before retrying. No commit was launched.');
|
|
187
|
+
}
|
|
188
|
+
async function exactPaths(root, supplied, receiptPaths = []) {
|
|
189
|
+
if (!supplied.length)
|
|
190
|
+
throw new Error('Supply at least one exact file path.');
|
|
191
|
+
const paths = [];
|
|
192
|
+
for (const value of supplied) {
|
|
193
|
+
if (!value || value.includes('\0'))
|
|
194
|
+
throw new Error('File paths must be nonblank literal paths.');
|
|
195
|
+
const absolute = resolve(root, value);
|
|
196
|
+
const path = relative(root, absolute);
|
|
197
|
+
if (!path ||
|
|
198
|
+
path === '..' ||
|
|
199
|
+
path.startsWith('../') ||
|
|
200
|
+
isAbsolute(path) ||
|
|
201
|
+
path.split('/').includes('.git'))
|
|
202
|
+
throw new Error(`Path is outside repository ownership: ${value}`);
|
|
203
|
+
let ancestor = dirname(absolute);
|
|
204
|
+
while (!(await access(ancestor).then(() => true, () => false)))
|
|
205
|
+
ancestor = dirname(ancestor);
|
|
206
|
+
const physical = relative(root, await realpath(ancestor));
|
|
207
|
+
if (physical === '..' || physical.startsWith('../'))
|
|
208
|
+
throw new Error(`Path traverses a symlink outside the repository: ${value}`);
|
|
209
|
+
const info = await lstat(absolute).catch((error) => {
|
|
210
|
+
if (error.code === 'ENOENT')
|
|
211
|
+
return undefined;
|
|
212
|
+
throw error;
|
|
213
|
+
});
|
|
214
|
+
if (info?.isDirectory())
|
|
215
|
+
throw new Error(`Directories are not exact file paths: ${value}`);
|
|
216
|
+
const headEntry = !info
|
|
217
|
+
? await git(root, ['ls-tree', '-z', 'HEAD', '--', path]).catch(() => '')
|
|
218
|
+
: '';
|
|
219
|
+
const trackedHeadFile = headEntry
|
|
220
|
+
.split('\0')
|
|
221
|
+
.some((entry) => /^(100644|100755|120000) blob [a-f0-9]+\t/.test(entry) &&
|
|
222
|
+
entry.slice(entry.indexOf('\t') + 1) === path);
|
|
223
|
+
if (!info &&
|
|
224
|
+
!receiptPaths.includes(path) &&
|
|
225
|
+
!trackedHeadFile &&
|
|
226
|
+
!(await git(root, ['ls-files', '-z', '--error-unmatch', '--', path]).then((output) => output.split('\0').includes(path), () => false)))
|
|
227
|
+
throw new Error(`Absent path is not a tracked removal: ${value}`);
|
|
228
|
+
const entry = await git(root, ['ls-files', '--stage', '--', path]);
|
|
229
|
+
if (/^160000 /m.test(entry) || / [123]\t/.test(entry))
|
|
230
|
+
throw new Error(`Unmerged paths and submodules require explicit resolution: ${value}`);
|
|
231
|
+
paths.push(path);
|
|
232
|
+
}
|
|
233
|
+
return [...new Set(paths)].sort();
|
|
234
|
+
}
|
|
235
|
+
/** Use an isolated index for enabled hooks; publish only owned entries under Git's lock.
|
|
236
|
+
* The real lock is held during hooks, so ordinary competing Git writers cannot lose
|
|
237
|
+
* their changes. A byte comparison also detects writers bypassing that protocol.
|
|
238
|
+
* Never restore a snapshot over current user data, and never remove a foreign lock.
|
|
239
|
+
*/
|
|
240
|
+
export async function commitExactPaths(input, overrides = {}) {
|
|
241
|
+
const policy = { ...defaults, ...overrides };
|
|
242
|
+
let receiptPath;
|
|
243
|
+
let commit;
|
|
244
|
+
let attempts = 0;
|
|
245
|
+
let root;
|
|
246
|
+
let indexPath;
|
|
247
|
+
let paths;
|
|
248
|
+
try {
|
|
249
|
+
if (!input.identity.trim() || !input.message.trim())
|
|
250
|
+
throw new Error('Message and operation identity must be nonblank.');
|
|
251
|
+
root = await realpath((await git(input.repoRoot, ['rev-parse', '--show-toplevel'])).trim());
|
|
252
|
+
// Refuse before creating receipt directories, index locks or temporary state.
|
|
253
|
+
await assertNoInProgressGitOperation(root);
|
|
254
|
+
indexPath = resolve(root, (await git(root, ['rev-parse', '--git-path', 'index'])).trim());
|
|
255
|
+
const receipts = join(dirname(indexPath), 'oat-exact-path-commits');
|
|
256
|
+
receiptPath = join(receipts, `${digest(input.identity)}.json`);
|
|
257
|
+
const prior = await bytes(receiptPath);
|
|
258
|
+
const priorPaths = prior
|
|
259
|
+
? JSON.parse(prior.toString()).paths
|
|
260
|
+
: [];
|
|
261
|
+
paths = await exactPaths(root, input.paths, priorPaths);
|
|
262
|
+
await mkdir(receipts, { recursive: true });
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
return {
|
|
266
|
+
outcome: 'failed',
|
|
267
|
+
committed: false,
|
|
268
|
+
attempts,
|
|
269
|
+
error: detail(error),
|
|
270
|
+
lockClass: 'other',
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
// Catchable termination must not strand our lock or release it while Git's
|
|
274
|
+
// enabled hooks can still write. An in-flight child is awaited normally;
|
|
275
|
+
// terminal-delivered signals may also stop Git, whose close settles its pipes.
|
|
276
|
+
// A nonterminating hook can delay this settlement indefinitely. Abrupt death
|
|
277
|
+
// is deliberately outside this operation-scoped handler.
|
|
278
|
+
let interrupted;
|
|
279
|
+
const onTerm = () => {
|
|
280
|
+
interrupted ??= 'SIGTERM';
|
|
281
|
+
};
|
|
282
|
+
const onInt = () => {
|
|
283
|
+
interrupted ??= 'SIGINT';
|
|
284
|
+
};
|
|
285
|
+
process.on('SIGTERM', onTerm);
|
|
286
|
+
process.on('SIGINT', onInt);
|
|
287
|
+
try {
|
|
288
|
+
const lockPath = `${indexPath}.lock`;
|
|
289
|
+
let firstMtime;
|
|
290
|
+
while (attempts < policy.attempts) {
|
|
291
|
+
if (interrupted)
|
|
292
|
+
return {
|
|
293
|
+
outcome: 'failed',
|
|
294
|
+
committed: false,
|
|
295
|
+
attempts,
|
|
296
|
+
receipt: receiptPath,
|
|
297
|
+
resumable: true,
|
|
298
|
+
error: `${interrupted} received before commit; retry the same identity.`,
|
|
299
|
+
lockClass: 'other',
|
|
300
|
+
};
|
|
301
|
+
attempts++;
|
|
302
|
+
let lock;
|
|
303
|
+
try {
|
|
304
|
+
lock = await open(lockPath, 'wx');
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
if (error.code !== 'EEXIST')
|
|
308
|
+
return {
|
|
309
|
+
outcome: 'failed',
|
|
310
|
+
committed: false,
|
|
311
|
+
attempts,
|
|
312
|
+
error: detail(error),
|
|
313
|
+
};
|
|
314
|
+
const current = await stat(lockPath).catch(() => undefined);
|
|
315
|
+
if (attempts === 1)
|
|
316
|
+
firstMtime = current?.mtimeMs;
|
|
317
|
+
if (attempts < policy.attempts) {
|
|
318
|
+
await policy.sleep(policy.retryDelaysMs[attempts - 1] ?? 0);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
outcome: 'blocked',
|
|
323
|
+
committed: false,
|
|
324
|
+
attempts,
|
|
325
|
+
receipt: receiptPath,
|
|
326
|
+
resumable: true,
|
|
327
|
+
lockClass: firstMtime !== undefined && firstMtime === current?.mtimeMs
|
|
328
|
+
? 'persistent-index-lock'
|
|
329
|
+
: 'transient-index-lock',
|
|
330
|
+
error: `Git index lock remains at ${lockPath}. Its owner must finish; retry the same identity. No lock was removed.`,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
const ownedLock = await lock.stat();
|
|
334
|
+
const stillOwnLock = async () => {
|
|
335
|
+
const current = await stat(lockPath).catch(() => undefined);
|
|
336
|
+
return current?.ino === ownedLock.ino && current.dev === ownedLock.dev;
|
|
337
|
+
};
|
|
338
|
+
let temporary;
|
|
339
|
+
let temporaryIdentity;
|
|
340
|
+
let operationResult;
|
|
341
|
+
let published = false;
|
|
342
|
+
let startedParent;
|
|
343
|
+
let settledCommit;
|
|
344
|
+
let storedCommittedReceipt = false;
|
|
345
|
+
let verifiedPriorCommit = false;
|
|
346
|
+
let resumable = true;
|
|
347
|
+
try {
|
|
348
|
+
if (interrupted)
|
|
349
|
+
throw new Error(`${interrupted} received before commit; no commit was launched.`);
|
|
350
|
+
await assertNoInProgressGitOperation(root);
|
|
351
|
+
const snapshot = await bytes(indexPath);
|
|
352
|
+
const head = await git(root, ['rev-parse', '--verify', 'HEAD']).catch(() => '');
|
|
353
|
+
const receiptBytes = await bytes(receiptPath);
|
|
354
|
+
let receipt = receiptBytes
|
|
355
|
+
? JSON.parse(receiptBytes.toString())
|
|
356
|
+
: undefined;
|
|
357
|
+
if (receipt &&
|
|
358
|
+
(receipt.identity !== input.identity ||
|
|
359
|
+
JSON.stringify(receipt.paths) !== JSON.stringify(paths)))
|
|
360
|
+
throw new Error('Operation identity already names different owned paths; supply a distinct identity.');
|
|
361
|
+
if (receipt?.commit) {
|
|
362
|
+
commit = receipt.commit;
|
|
363
|
+
storedCommittedReceipt = true;
|
|
364
|
+
}
|
|
365
|
+
else if (receipt) {
|
|
366
|
+
// Recover the window after Git wrote the commit but before receipt publication.
|
|
367
|
+
const candidates = (await git(root, [
|
|
368
|
+
'log',
|
|
369
|
+
'--format=%H',
|
|
370
|
+
'--fixed-strings',
|
|
371
|
+
`--grep=Oat-Operation: ${digest(input.identity)}`,
|
|
372
|
+
]))
|
|
373
|
+
.split('\n')
|
|
374
|
+
.filter(Boolean);
|
|
375
|
+
for (const candidate of candidates) {
|
|
376
|
+
if ((await git(root, ['show', '-s', '--format=%P', candidate]).catch(() => '')) === receipt.parent) {
|
|
377
|
+
commit = candidate;
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (candidates.length && !commit) {
|
|
382
|
+
resumable = false;
|
|
383
|
+
throw new Error('Receipt does not positively match the committed artifact: operation-trailer candidates exist, but none matches the expected receipt parent. Preserve the receipt for inspection and explicit provenance reconciliation; an unchanged same-identity retry cannot resolve this condition.');
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (commit && receipt) {
|
|
387
|
+
// Resolution and persistence are separate: history recovery must pass
|
|
388
|
+
// the same provenance checks without pretending metadata was stored.
|
|
389
|
+
const tree = await git(root, ['rev-parse', `${commit}^{tree}`]);
|
|
390
|
+
if ((await git(root, ['show', '-s', '--format=%P', commit])) !==
|
|
391
|
+
receipt.parent ||
|
|
392
|
+
((storedCommittedReceipt || receipt.tree !== undefined) &&
|
|
393
|
+
tree !== receipt.tree) ||
|
|
394
|
+
!(await git(root, ['show', '-s', '--format=%B', commit])).includes(`Oat-Operation: ${digest(input.identity)}`))
|
|
395
|
+
throw new Error('Receipt does not positively match the committed artifact.');
|
|
396
|
+
await git(root, ['merge-base', '--is-ancestor', commit, head]);
|
|
397
|
+
receipt = { ...receipt, commit, tree };
|
|
398
|
+
verifiedPriorCommit = true;
|
|
399
|
+
}
|
|
400
|
+
const resuming = commit !== undefined;
|
|
401
|
+
temporary = await mkdtemp(join(dirname(indexPath), 'oat-commit-'));
|
|
402
|
+
temporaryIdentity = await lstat(temporary);
|
|
403
|
+
const isolated = join(temporary, 'index');
|
|
404
|
+
if (!commit) {
|
|
405
|
+
await git(root, head ? ['read-tree', head] : ['read-tree', '--empty'], isolated);
|
|
406
|
+
await git(root, ['add', '-A', '--', ...paths], isolated);
|
|
407
|
+
if (!(await git(root, ['diff', '--cached', '--name-only'], isolated)))
|
|
408
|
+
return (operationResult = {
|
|
409
|
+
outcome: 'nothing',
|
|
410
|
+
committed: false,
|
|
411
|
+
attempts,
|
|
412
|
+
});
|
|
413
|
+
const unrelated = (await git(root, [
|
|
414
|
+
'ls-files',
|
|
415
|
+
'-z',
|
|
416
|
+
'--cached',
|
|
417
|
+
'--others',
|
|
418
|
+
'--exclude-standard',
|
|
419
|
+
]))
|
|
420
|
+
.split('\0')
|
|
421
|
+
.filter((path) => path && !paths.includes(path));
|
|
422
|
+
const worktree = Object.fromEntries(await Promise.all([...new Set(unrelated)].map(async (path) => [
|
|
423
|
+
path,
|
|
424
|
+
fileIdentity(join(root, path), root),
|
|
425
|
+
])));
|
|
426
|
+
const configured = await git(root, [
|
|
427
|
+
'config',
|
|
428
|
+
'--path',
|
|
429
|
+
'--get',
|
|
430
|
+
'core.hooksPath',
|
|
431
|
+
]).catch(() => '');
|
|
432
|
+
const originalHooks = configured
|
|
433
|
+
? resolve(root, configured)
|
|
434
|
+
: resolve(root, await git(root, ['rev-parse', '--git-path', 'hooks']));
|
|
435
|
+
const hooks = join(temporary, 'hooks');
|
|
436
|
+
await mkdir(hooks);
|
|
437
|
+
const shellQuote = (value) => `'${value.replaceAll("'", "'\"'\"'")}'`;
|
|
438
|
+
const guardedHooks = [
|
|
439
|
+
'pre-commit',
|
|
440
|
+
'prepare-commit-msg',
|
|
441
|
+
'commit-msg',
|
|
442
|
+
'post-commit',
|
|
443
|
+
];
|
|
444
|
+
const originalNames = await readdir(originalHooks).catch((error) => {
|
|
445
|
+
// Missing directories and disabled hooksPath=/dev/null have no hooks.
|
|
446
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
|
447
|
+
return [];
|
|
448
|
+
throw error;
|
|
449
|
+
});
|
|
450
|
+
// Preserve every other executable hook Git may invoke, including ref
|
|
451
|
+
// transactions and index notifications. Their protocols do not accept
|
|
452
|
+
// the commit-staging guard: exec preserves argv, streams and exit status.
|
|
453
|
+
for (const name of originalNames) {
|
|
454
|
+
if (guardedHooks.includes(name))
|
|
455
|
+
continue;
|
|
456
|
+
const source = join(originalHooks, name);
|
|
457
|
+
if (!(await access(source, constants.X_OK).then(() => true, () => false)))
|
|
458
|
+
continue;
|
|
459
|
+
await writeFile(join(hooks, name), `#!/bin/sh\nexec ${shellQuote(source)} "$@"\n`, { mode: 0o700 });
|
|
460
|
+
}
|
|
461
|
+
// Delegate original executable commit hooks, then guard the emitted
|
|
462
|
+
// tree before Git accepts it. Keep their existing isolated-index guards.
|
|
463
|
+
for (const name of guardedHooks) {
|
|
464
|
+
const source = join(originalHooks, name);
|
|
465
|
+
const executable = await access(source, constants.X_OK).then(() => true, () => false);
|
|
466
|
+
if (!executable && name === 'post-commit')
|
|
467
|
+
continue;
|
|
468
|
+
const script = `#!/usr/bin/env node\nconst {spawnSync}=require('node:child_process');const fs=require('node:fs');const crypto=require('node:crypto');const path=require('node:path');\n// tsx retains inferred function names with this identity decorator.\nconst __name=(fn)=>fn;\n${preservationIdentity.toString()}\n${executable ? `const hook=spawnSync(${JSON.stringify(source)},process.argv.slice(2),{stdio:'inherit'});if(hook.error||hook.status!==0)process.exit(hook.status||1);` : ''}\n${name !== 'post-commit' ? `const owned=${JSON.stringify(paths)};const staged=spawnSync('git',['diff','--cached','--name-only','-z'],{encoding:'utf8'});if(staged.status!==0||staged.stdout.split('\\0').some(p=>p&&!owned.includes(p))){process.stderr.write('Exact-path ownership guard: hook staged an unowned path.\\n');process.exit(1);}const before=${JSON.stringify(worktree)};for(const [p,expected]of Object.entries(before)){const actual=preservationIdentity(path.resolve(p),${JSON.stringify(root)},{fs,path,spawnSync,createHash:crypto.createHash});if(actual!==expected){process.stderr.write('Exact-path preservation guard: unowned worktree path changed: '+p+'\\n');process.exit(1);}}` : ''}\n`;
|
|
469
|
+
// Git requires extensionless hook names. Launch an explicitly CommonJS
|
|
470
|
+
// companion so the repository's package type cannot reinterpret it.
|
|
471
|
+
const companion = join(hooks, `${name}.cjs`);
|
|
472
|
+
await writeFile(companion, script);
|
|
473
|
+
await writeFile(join(hooks, name), `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(companion)} "$@"\n`, { mode: 0o700 });
|
|
474
|
+
}
|
|
475
|
+
if (interrupted)
|
|
476
|
+
throw new Error(`${interrupted} received before commit; no commit was launched.`);
|
|
477
|
+
// Recheck the real index and operation state immediately before launch.
|
|
478
|
+
await assertNoInProgressGitOperation(root);
|
|
479
|
+
receipt = { identity: input.identity, paths, parent: head };
|
|
480
|
+
await writeReceipt(receiptPath, receipt);
|
|
481
|
+
if (interrupted)
|
|
482
|
+
throw new Error(`${interrupted} received before commit; no commit was launched.`);
|
|
483
|
+
startedParent = head;
|
|
484
|
+
await git(root, [
|
|
485
|
+
'-c',
|
|
486
|
+
`core.hooksPath=${hooks}`,
|
|
487
|
+
'commit',
|
|
488
|
+
'-m',
|
|
489
|
+
`${input.message}\n\nOat-Operation: ${digest(input.identity)}`,
|
|
490
|
+
], isolated);
|
|
491
|
+
commit = await git(root, ['rev-parse', 'HEAD']);
|
|
492
|
+
if ((await git(root, ['show', '-s', '--format=%P', commit])) !== head ||
|
|
493
|
+
!(await git(root, ['show', '-s', '--format=%B', commit])).includes(`Oat-Operation: ${digest(input.identity)}`))
|
|
494
|
+
throw new Error('Commit identity/parent could not be verified.');
|
|
495
|
+
for (const [path, before] of Object.entries(worktree)) {
|
|
496
|
+
if (fileIdentity(join(root, path), root) !== before)
|
|
497
|
+
throw new Error(`Unowned worktree path changed during hooks: ${path}; current bytes preserved for inspection.`);
|
|
498
|
+
}
|
|
499
|
+
receipt = {
|
|
500
|
+
...receipt,
|
|
501
|
+
commit,
|
|
502
|
+
tree: await git(root, ['rev-parse', `${commit}^{tree}`]),
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
const emitted = (await git(root, [
|
|
506
|
+
'diff-tree',
|
|
507
|
+
'--root',
|
|
508
|
+
'--no-commit-id',
|
|
509
|
+
'-r',
|
|
510
|
+
'--no-renames',
|
|
511
|
+
'--name-only',
|
|
512
|
+
'-z',
|
|
513
|
+
commit,
|
|
514
|
+
]))
|
|
515
|
+
.split('\0')
|
|
516
|
+
.filter(Boolean);
|
|
517
|
+
if (emitted.some((path) => !paths.includes(path)))
|
|
518
|
+
throw new Error('Committed artifact contains unowned paths; manual inspection required.');
|
|
519
|
+
if (resuming && !(await bytes(receiptPath))?.equals(receiptBytes))
|
|
520
|
+
throw new Error('Verified receipt was replaced; replacement preserved for inspection.');
|
|
521
|
+
if (resuming &&
|
|
522
|
+
(await git(root, [
|
|
523
|
+
'diff',
|
|
524
|
+
'--name-only',
|
|
525
|
+
commit,
|
|
526
|
+
head,
|
|
527
|
+
'--',
|
|
528
|
+
...paths,
|
|
529
|
+
])).length) {
|
|
530
|
+
// A later owned generation cannot authorize republishing the old tree.
|
|
531
|
+
// Only a validated receipt and the current published HEAD allow its
|
|
532
|
+
// marker owner to retire the obsolete reservation, even when the new
|
|
533
|
+
// producer worktree equals the old tree (prune -> recreate -> prune).
|
|
534
|
+
const current = join(temporary, 'current');
|
|
535
|
+
if (snapshot)
|
|
536
|
+
await writeFile(current, snapshot);
|
|
537
|
+
else
|
|
538
|
+
await git(root, ['read-tree', '--empty'], current);
|
|
539
|
+
if (verifiedPriorCommit &&
|
|
540
|
+
!(await git(root, ['diff', '--cached', '--name-only', head, '--', ...paths], current)).length &&
|
|
541
|
+
(await bytes(indexPath))?.equals(snapshot ?? Buffer.alloc(0)) &&
|
|
542
|
+
(await bytes(receiptPath))?.equals(receiptBytes) &&
|
|
543
|
+
(await git(root, ['rev-parse', '--verify', 'HEAD'])) === head &&
|
|
544
|
+
(await stillOwnLock()))
|
|
545
|
+
settledCommit = commit;
|
|
546
|
+
throw new Error('Owned artifact changed since this operation committed; use a new identity.');
|
|
547
|
+
}
|
|
548
|
+
if (!storedCommittedReceipt)
|
|
549
|
+
await writeReceipt(receiptPath, receipt);
|
|
550
|
+
// Copy current index into a disposable merge, never into the live index.
|
|
551
|
+
// Only owned entries are reset to the verified hook-final tree.
|
|
552
|
+
const merged = join(temporary, 'merged');
|
|
553
|
+
if (snapshot)
|
|
554
|
+
await writeFile(merged, snapshot);
|
|
555
|
+
else
|
|
556
|
+
await git(root, ['read-tree', '--empty'], merged);
|
|
557
|
+
const ownedEntriesSettled = storedCommittedReceipt &&
|
|
558
|
+
!(await git(root, ['diff', '--cached', '--name-only', commit, '--', ...paths], merged)).length;
|
|
559
|
+
await git(root, ['reset', '--quiet', commit, '--', ...paths], merged);
|
|
560
|
+
if ((await git(root, ['diff', '--name-only', commit, '--', ...paths], merged)).length) {
|
|
561
|
+
if (ownedEntriesSettled &&
|
|
562
|
+
(await bytes(indexPath))?.equals(snapshot ?? Buffer.alloc(0)) &&
|
|
563
|
+
(await bytes(receiptPath))?.equals(receiptBytes) &&
|
|
564
|
+
(await stillOwnLock()))
|
|
565
|
+
settledCommit = commit;
|
|
566
|
+
throw new Error('Committed owned bytes differ from the worktree; inspect changes before retrying.');
|
|
567
|
+
}
|
|
568
|
+
if (!(await bytes(indexPath))?.equals(snapshot ?? Buffer.alloc(0)) &&
|
|
569
|
+
(snapshot !== undefined || (await bytes(indexPath)) !== undefined))
|
|
570
|
+
return (operationResult = {
|
|
571
|
+
outcome: 'blocked',
|
|
572
|
+
committed: true,
|
|
573
|
+
commit,
|
|
574
|
+
attempts,
|
|
575
|
+
receipt: receiptPath,
|
|
576
|
+
resumable: true,
|
|
577
|
+
error: 'Concurrent real-index change detected. The verified commit exists; current index was preserved. Retry the same identity to finalize owned entries.',
|
|
578
|
+
});
|
|
579
|
+
if (!(await stillOwnLock()))
|
|
580
|
+
throw new Error('Owned index lock was replaced; current index and foreign lock preserved. Retry after inspection.');
|
|
581
|
+
await lock.writeFile(await readFile(merged));
|
|
582
|
+
await lock.close();
|
|
583
|
+
await rename(lockPath, indexPath);
|
|
584
|
+
published = true;
|
|
585
|
+
return (operationResult = {
|
|
586
|
+
outcome: resuming ? 'already-matching' : 'committed',
|
|
587
|
+
committed: true,
|
|
588
|
+
commit,
|
|
589
|
+
attempts,
|
|
590
|
+
receipt: receiptPath,
|
|
591
|
+
...(interrupted
|
|
592
|
+
? {
|
|
593
|
+
error: `${interrupted} received; committed identity and owned index were verified and settled before cleanup.`,
|
|
594
|
+
}
|
|
595
|
+
: {}),
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
// A terminal signal can stop Git after its ref update. Report only a
|
|
600
|
+
// positively matched commit; the existing receipt retry owns publication.
|
|
601
|
+
if (interrupted && !commit && startedParent !== undefined) {
|
|
602
|
+
const candidate = await git(root, [
|
|
603
|
+
'rev-parse',
|
|
604
|
+
'--verify',
|
|
605
|
+
'HEAD',
|
|
606
|
+
]).catch(() => '');
|
|
607
|
+
if (candidate) {
|
|
608
|
+
const parent = await git(root, [
|
|
609
|
+
'show',
|
|
610
|
+
'-s',
|
|
611
|
+
'--format=%P',
|
|
612
|
+
candidate,
|
|
613
|
+
]).catch(() => undefined);
|
|
614
|
+
const message = await git(root, [
|
|
615
|
+
'show',
|
|
616
|
+
'-s',
|
|
617
|
+
'--format=%B',
|
|
618
|
+
candidate,
|
|
619
|
+
]).catch(() => '');
|
|
620
|
+
const emitted = await git(root, [
|
|
621
|
+
'diff-tree',
|
|
622
|
+
'--root',
|
|
623
|
+
'--no-commit-id',
|
|
624
|
+
'-r',
|
|
625
|
+
'--no-renames',
|
|
626
|
+
'--name-only',
|
|
627
|
+
'-z',
|
|
628
|
+
candidate,
|
|
629
|
+
]).catch(() => undefined);
|
|
630
|
+
if (parent === startedParent &&
|
|
631
|
+
message.includes(`Oat-Operation: ${digest(input.identity)}`) &&
|
|
632
|
+
emitted !== undefined &&
|
|
633
|
+
emitted
|
|
634
|
+
.split('\0')
|
|
635
|
+
.filter(Boolean)
|
|
636
|
+
.every((entry) => paths.includes(entry)))
|
|
637
|
+
commit = candidate;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return (operationResult = {
|
|
641
|
+
outcome: 'failed',
|
|
642
|
+
committed: commit !== undefined,
|
|
643
|
+
commit,
|
|
644
|
+
...(settledCommit ? { settledCommit } : {}),
|
|
645
|
+
attempts,
|
|
646
|
+
receipt: receiptPath,
|
|
647
|
+
resumable,
|
|
648
|
+
error: `${detail(error)}${interrupted ? ` ${interrupted} received; active Git settled before owned cleanup.${resumable ? ' Retry the same identity to finalize any reported commit.' : ''}` : ''}`,
|
|
649
|
+
lockClass: 'other',
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
finally {
|
|
653
|
+
if (!published) {
|
|
654
|
+
await lock.close().catch(() => undefined);
|
|
655
|
+
if (await stillOwnLock())
|
|
656
|
+
await rm(lockPath, { force: true });
|
|
657
|
+
else if (operationResult)
|
|
658
|
+
delete operationResult.settledCommit;
|
|
659
|
+
}
|
|
660
|
+
if (temporary && temporaryIdentity) {
|
|
661
|
+
const current = await lstat(temporary).catch((error) => {
|
|
662
|
+
if (error.code === 'ENOENT')
|
|
663
|
+
return undefined;
|
|
664
|
+
throw error;
|
|
665
|
+
});
|
|
666
|
+
if (current?.ino === temporaryIdentity.ino &&
|
|
667
|
+
current.dev === temporaryIdentity.dev)
|
|
668
|
+
await rm(temporary, { recursive: true, force: true });
|
|
669
|
+
else if (operationResult) {
|
|
670
|
+
delete operationResult.settledCommit;
|
|
671
|
+
operationResult.outcome = 'failed';
|
|
672
|
+
operationResult.resumable = true;
|
|
673
|
+
operationResult.error =
|
|
674
|
+
`${operationResult.error ?? ''} Operation temporary directory was replaced or removed; replacement preserved for inspection: ${temporary}`.trim();
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return {
|
|
680
|
+
outcome: 'blocked',
|
|
681
|
+
committed: false,
|
|
682
|
+
attempts,
|
|
683
|
+
resumable: true,
|
|
684
|
+
error: 'Commit attempt budget must be positive.',
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
finally {
|
|
688
|
+
process.off('SIGTERM', onTerm);
|
|
689
|
+
process.off('SIGINT', onInt);
|
|
690
|
+
}
|
|
691
|
+
}
|