release-skill 0.1.9 → 0.2.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +53 -0
- package/INSTALL.md +4 -4
- package/INSTALL.zh-CN.md +4 -4
- package/README.md +18 -33
- package/README.zh-CN.md +17 -23
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +2740 -1844
- package/adapters/claude/schemas/.render-manifest.json +8 -8
- package/adapters/claude/schemas/approval-record.schema.json +1 -1
- package/adapters/claude/schemas/release-plan.schema.json +6 -2
- package/adapters/claude/schemas/release-project.schema.json +14 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +2740 -1844
- package/adapters/codex/schemas/.render-manifest.json +8 -8
- package/adapters/codex/schemas/approval-record.schema.json +1 -1
- package/adapters/codex/schemas/release-plan.schema.json +6 -2
- package/adapters/codex/schemas/release-project.schema.json +14 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +2740 -1844
- package/adapters/kimi/schemas/.render-manifest.json +8 -8
- package/adapters/kimi/schemas/approval-record.schema.json +1 -1
- package/adapters/kimi/schemas/release-plan.schema.json +6 -2
- package/adapters/kimi/schemas/release-project.schema.json +14 -0
- package/bin/release-skill-cli.mjs +3 -0
- package/bin/release-skill.bundle.mjs +2740 -1844
- package/package.json +8 -2
- package/references/.render-manifest.json +8 -8
- package/references/01-state-machine.md +5 -5
- package/references/02-project-config.md +1 -1
- package/references/05-evidence-and-errors.md +1 -1
- package/references/06-adapter-contract.md +41 -1
- package/schemas/.render-manifest.json +8 -8
- package/schemas/approval-record.schema.json +1 -1
- package/schemas/release-plan.schema.json +6 -2
- package/schemas/release-project.schema.json +14 -0
- package/scripts/sync-public-files.mjs +462 -0
- package/src/adapters/contract.mjs +60 -0
- package/src/adapters/plugin-marketplace.mjs +289 -730
- package/src/commands/prepare.mjs +195 -182
- package/src/commands/publish.mjs +438 -122
- package/src/commands/reconcile.mjs +369 -191
- package/src/commands/verify.mjs +13 -2
- package/src/core/approval.mjs +72 -45
- package/src/core/baseline.mjs +16 -0
- package/src/core/checkpoints.mjs +143 -0
- package/src/core/evidence.mjs +30 -3
- package/src/core/hook-cache.mjs +254 -0
- package/src/core/hooks.mjs +37 -1
- package/src/core/observe-retry.mjs +223 -0
- package/src/core/plan.mjs +162 -253
- package/src/platforms/kimi.mjs +514 -0
- package/src/platforms/registry.mjs +393 -0
- package/src/producers/build-adapters.mjs +14 -22
- package/src/snapshot/frozen.mjs +29 -5
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incremental hook result cache (T3.2).
|
|
3
|
+
*
|
|
4
|
+
* A hook that opts in with `cacheable: true` and a `cacheInputs` glob list is
|
|
5
|
+
* keyed by the fingerprint of its full configuration plus the content of every
|
|
6
|
+
* file its inputs match. When the key is unchanged and the last run succeeded,
|
|
7
|
+
* prepare replays the cached outcome instead of re-executing the hook.
|
|
8
|
+
*
|
|
9
|
+
* Safety contract (see t3-2-incremental-hooks.md §4.8):
|
|
10
|
+
* - Failures are never cached. Only an `exitCode === 0` result is written; a
|
|
11
|
+
* non-zero exit or HOOK_TIMEOUT leaves no record, so the next run re-executes.
|
|
12
|
+
* - The cache only ever skips execution. It runs AFTER the hook authorization
|
|
13
|
+
* gate and never bypasses any GATE; hook order and failure semantics are
|
|
14
|
+
* untouched.
|
|
15
|
+
* - Fail-closed inputs: if any declared `cacheInputs` glob matches no file, the
|
|
16
|
+
* input set is considered a declaration error and caching aborts with
|
|
17
|
+
* GATE_FAILED before the hook runs (no execution, no cache).
|
|
18
|
+
* - Default zero change: a hook without `cacheable: true` never touches the
|
|
19
|
+
* cache directory at all.
|
|
20
|
+
*
|
|
21
|
+
* The cache is a pure local optimisation under `.release-skill/cache` (a
|
|
22
|
+
* registered control-plane prefix, excluded from workspaceDigest and
|
|
23
|
+
* .gitignore). Deleting it is equivalent to a cold miss for every hook.
|
|
24
|
+
*
|
|
25
|
+
* @module hook-cache
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { readdir, readFile, mkdir, writeFile } from 'node:fs/promises';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { canonicalJson, sha256Hex } from './digest.mjs';
|
|
31
|
+
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
32
|
+
|
|
33
|
+
/** Control-plane location of hook cache records: `.release-skill/cache/hooks`. */
|
|
34
|
+
const CACHE_BASE = ['.release-skill', 'cache', 'hooks'];
|
|
35
|
+
|
|
36
|
+
/** Bounded tail length stored per stream (matches prepare's evidence tails). */
|
|
37
|
+
const TAIL_LENGTH = 4000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Directory names never walked when enumerating hook inputs. `.git` and
|
|
41
|
+
* `node_modules` are VCS/dependency internals; `.release-skill` is the control
|
|
42
|
+
* plane and MUST stay excluded so cache records never fingerprint themselves
|
|
43
|
+
* (which would destabilise every subsequent key).
|
|
44
|
+
*/
|
|
45
|
+
const SKIPPED_DIRS = new Set(['.git', 'node_modules', '.release-skill']);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Translate a `cacheInputs` glob into an anchored RegExp.
|
|
49
|
+
*
|
|
50
|
+
* Supported syntax (sufficient for input declarations):
|
|
51
|
+
* - `**` matches any run of characters, including `/` (crosses directories)
|
|
52
|
+
* - `*` matches any run of characters except `/`
|
|
53
|
+
* - `?` matches a single character except `/`
|
|
54
|
+
* - every other character matches literally (regex metacharacters escaped)
|
|
55
|
+
*
|
|
56
|
+
* @param {string} glob
|
|
57
|
+
* @returns {RegExp}
|
|
58
|
+
*/
|
|
59
|
+
function globToRegExp(glob) {
|
|
60
|
+
let source = '';
|
|
61
|
+
let i = 0;
|
|
62
|
+
while (i < glob.length) {
|
|
63
|
+
const c = glob[i];
|
|
64
|
+
if (c === '*') {
|
|
65
|
+
if (glob[i + 1] === '*') {
|
|
66
|
+
source += '.*';
|
|
67
|
+
i += 2;
|
|
68
|
+
} else {
|
|
69
|
+
source += '[^/]*';
|
|
70
|
+
i += 1;
|
|
71
|
+
}
|
|
72
|
+
} else if (c === '?') {
|
|
73
|
+
source += '[^/]';
|
|
74
|
+
i += 1;
|
|
75
|
+
} else {
|
|
76
|
+
source += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
77
|
+
i += 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return new RegExp(`^${source}$`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Recursively list every regular file under `root` as a `/`-separated relative
|
|
85
|
+
* path, skipping VCS/dependency/control-plane directories and symlinks (inputs
|
|
86
|
+
* are real files; symlink handling stays deterministic by not following them).
|
|
87
|
+
*
|
|
88
|
+
* @param {string} root - Absolute project root.
|
|
89
|
+
* @returns {Promise<string[]>} Sorted relative paths.
|
|
90
|
+
*/
|
|
91
|
+
async function listInputFiles(root) {
|
|
92
|
+
const out = [];
|
|
93
|
+
|
|
94
|
+
async function walk(dirAbs, dirRel) {
|
|
95
|
+
let entries;
|
|
96
|
+
try {
|
|
97
|
+
entries = await readdir(dirAbs, { withFileTypes: true });
|
|
98
|
+
} catch {
|
|
99
|
+
return; // Unreadable directory: treat as no inputs there.
|
|
100
|
+
}
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
if (entry.isDirectory()) {
|
|
103
|
+
if (SKIPPED_DIRS.has(entry.name)) continue;
|
|
104
|
+
const rel = dirRel ? `${dirRel}/${entry.name}` : entry.name;
|
|
105
|
+
await walk(join(dirAbs, entry.name), rel);
|
|
106
|
+
} else if (entry.isFile()) {
|
|
107
|
+
out.push(dirRel ? `${dirRel}/${entry.name}` : entry.name);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await walk(root, '');
|
|
113
|
+
out.sort();
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Compute the cache key for a cacheable hook.
|
|
119
|
+
*
|
|
120
|
+
* `cacheKey = sha256( canonicalJSON(hook config) + canonicalJSON(sorted matched
|
|
121
|
+
* files [{ path, sha256(content) }]) )`
|
|
122
|
+
*
|
|
123
|
+
* The full hook configuration (command/cwd/timeoutMs/envAllowlist/cacheInputs/
|
|
124
|
+
* cacheable) is part of the key, so any config change switches the key. Matched
|
|
125
|
+
* files are sorted by path before hashing for determinism.
|
|
126
|
+
*
|
|
127
|
+
* @param {Object} hook - A hook descriptor with a non-empty `cacheInputs`.
|
|
128
|
+
* @param {string} root - Absolute project root.
|
|
129
|
+
* @returns {Promise<{ cacheKey: string, matchedFiles: string[] }>}
|
|
130
|
+
* @throws {ReleaseError} GATE_FAILED when any declared glob matches no file.
|
|
131
|
+
*/
|
|
132
|
+
export async function computeHookCacheKey(hook, root) {
|
|
133
|
+
const globs = Array.isArray(hook.cacheInputs) ? hook.cacheInputs : [];
|
|
134
|
+
const matchers = globs.map((glob) => ({ glob, re: globToRegExp(glob) }));
|
|
135
|
+
|
|
136
|
+
const allFiles = await listInputFiles(root);
|
|
137
|
+
const matched = [];
|
|
138
|
+
const hitPerGlob = matchers.map(() => false);
|
|
139
|
+
for (const relPath of allFiles) {
|
|
140
|
+
let hit = false;
|
|
141
|
+
for (let i = 0; i < matchers.length; i += 1) {
|
|
142
|
+
if (matchers[i].re.test(relPath)) {
|
|
143
|
+
hitPerGlob[i] = true;
|
|
144
|
+
hit = true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (hit) matched.push(relPath);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Fail-closed: a glob that matches nothing is a declaration error (a typo or
|
|
151
|
+
// a missing input). Refuse to cache rather than risk a false hit.
|
|
152
|
+
for (let i = 0; i < matchers.length; i += 1) {
|
|
153
|
+
if (!hitPerGlob[i]) {
|
|
154
|
+
throw new ReleaseError(
|
|
155
|
+
GATE_FAILED,
|
|
156
|
+
`hook cacheInputs glob "${matchers[i].glob}" matched no files; refusing to cache`,
|
|
157
|
+
{ glob: matchers[i].glob },
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
matched.sort();
|
|
163
|
+
const fileEntries = [];
|
|
164
|
+
for (const relPath of matched) {
|
|
165
|
+
const content = await readFile(join(root, relPath));
|
|
166
|
+
fileEntries.push({ path: relPath, sha256: sha256Hex(content) });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const cacheKey = sha256Hex(canonicalJson(hook) + canonicalJson(fileEntries));
|
|
170
|
+
return { cacheKey, matchedFiles: matched };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Resolve the cache directory for a named hook.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} root
|
|
177
|
+
* @param {string} hookName
|
|
178
|
+
* @returns {string} Absolute path to `.release-skill/cache/hooks/<hookName>`.
|
|
179
|
+
*/
|
|
180
|
+
export function hookCacheDir(root, hookName) {
|
|
181
|
+
return join(root, ...CACHE_BASE, hookName);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Resolve the cache record path for a hook + key.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} root
|
|
188
|
+
* @param {string} hookName
|
|
189
|
+
* @param {string} cacheKey
|
|
190
|
+
* @returns {string} Absolute path to the `<cacheKey>.json` record.
|
|
191
|
+
*/
|
|
192
|
+
export function hookCachePath(root, hookName, cacheKey) {
|
|
193
|
+
return join(hookCacheDir(root, hookName), `${cacheKey}.json`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Read a cached hook result. Returns the record only when it exists, its key
|
|
198
|
+
* matches, and it recorded a successful (`exitCode === 0`) run; anything else
|
|
199
|
+
* (missing, corrupt, or non-zero) is a miss.
|
|
200
|
+
*
|
|
201
|
+
* @param {string} root
|
|
202
|
+
* @param {string} hookName
|
|
203
|
+
* @param {string} cacheKey
|
|
204
|
+
* @returns {Promise<Object | null>}
|
|
205
|
+
*/
|
|
206
|
+
export async function readHookCache(root, hookName, cacheKey) {
|
|
207
|
+
try {
|
|
208
|
+
const raw = await readFile(hookCachePath(root, hookName, cacheKey), 'utf8');
|
|
209
|
+
const record = JSON.parse(raw);
|
|
210
|
+
if (record && record.cacheKey === cacheKey && record.exitCode === 0) {
|
|
211
|
+
return record;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Write a successful hook result to the cache. Never throws: a write failure
|
|
221
|
+
* returns `{ ok: false, error }` so the caller can record a warning without
|
|
222
|
+
* aborting prepare (the cache is an optimisation, not a gate).
|
|
223
|
+
*
|
|
224
|
+
* @param {string} root
|
|
225
|
+
* @param {string} hookName
|
|
226
|
+
* @param {string} cacheKey
|
|
227
|
+
* @param {Object} result
|
|
228
|
+
* @param {number} result.exitCode - Must be 0; non-zero results are not cached.
|
|
229
|
+
* @param {string} [result.stdoutTail] - Already truncated to TAIL_LENGTH.
|
|
230
|
+
* @param {string} [result.stderrTail] - Already truncated to TAIL_LENGTH.
|
|
231
|
+
* @param {string} [result.createdAt] - ISO timestamp (defaults to now).
|
|
232
|
+
* @returns {Promise<{ ok: true } | { ok: false, error: string }>}
|
|
233
|
+
*/
|
|
234
|
+
export async function writeHookCache(root, hookName, cacheKey, result) {
|
|
235
|
+
// Defence in depth: a failure must never be persisted, even if a caller
|
|
236
|
+
// mistakenly passes a non-zero exit code.
|
|
237
|
+
if (!result || result.exitCode !== 0) {
|
|
238
|
+
return { ok: false, error: 'refusing to cache a non-zero exit result' };
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
await mkdir(hookCacheDir(root, hookName), { recursive: true });
|
|
242
|
+
const record = {
|
|
243
|
+
cacheKey,
|
|
244
|
+
exitCode: 0,
|
|
245
|
+
stdoutTail: String(result.stdoutTail ?? '').slice(-TAIL_LENGTH),
|
|
246
|
+
stderrTail: String(result.stderrTail ?? '').slice(-TAIL_LENGTH),
|
|
247
|
+
createdAt: result.createdAt ?? new Date().toISOString(),
|
|
248
|
+
};
|
|
249
|
+
await writeFile(hookCachePath(root, hookName, cacheKey), `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
|
250
|
+
return { ok: true };
|
|
251
|
+
} catch (err) {
|
|
252
|
+
return { ok: false, error: err.message };
|
|
253
|
+
}
|
|
254
|
+
}
|
package/src/core/hooks.mjs
CHANGED
|
@@ -54,7 +54,7 @@ function validateHook(hook) {
|
|
|
54
54
|
throw new ReleaseError('INVALID_HOOK', 'hook must be a non-null object');
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
const { command, cwd, timeoutMs, envAllowlist } = hook;
|
|
57
|
+
const { command, cwd, timeoutMs, envAllowlist, cacheable, cacheInputs } = hook;
|
|
58
58
|
|
|
59
59
|
// command: required, non-empty array of strings
|
|
60
60
|
if (!Array.isArray(command) || command.length === 0) {
|
|
@@ -92,6 +92,40 @@ function validateHook(hook) {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
|
+
|
|
96
|
+
// cacheable: optional boolean opting the hook into the incremental result
|
|
97
|
+
// cache (T3.2). Absence means the hook always runs in full (default).
|
|
98
|
+
if (cacheable !== undefined && typeof cacheable !== 'boolean') {
|
|
99
|
+
throw new ReleaseError('INVALID_HOOK', 'hook.cacheable must be a boolean when provided');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// cacheInputs: optional non-empty array of non-empty glob strings declaring
|
|
103
|
+
// the hook's full input set (used to fingerprint the cache key).
|
|
104
|
+
if (cacheInputs !== undefined) {
|
|
105
|
+
if (!Array.isArray(cacheInputs) || cacheInputs.length === 0) {
|
|
106
|
+
throw new ReleaseError(
|
|
107
|
+
'INVALID_HOOK',
|
|
108
|
+
'hook.cacheInputs must be a non-empty array of glob strings when provided',
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
for (const glob of cacheInputs) {
|
|
112
|
+
if (typeof glob !== 'string' || glob.length === 0) {
|
|
113
|
+
throw new ReleaseError(
|
|
114
|
+
'INVALID_HOOK',
|
|
115
|
+
'every element of hook.cacheInputs must be a non-empty string',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// A cacheable hook MUST declare its inputs: a cache without an input
|
|
122
|
+
// declaration is untrustworthy (any unseen change could cause a false hit).
|
|
123
|
+
if (cacheable === true && (!Array.isArray(cacheInputs) || cacheInputs.length === 0)) {
|
|
124
|
+
throw new ReleaseError(
|
|
125
|
+
'INVALID_HOOK',
|
|
126
|
+
'hook.cacheable=true requires a non-empty hook.cacheInputs',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
95
129
|
}
|
|
96
130
|
|
|
97
131
|
// ---------------------------------------------------------------------------
|
|
@@ -146,6 +180,8 @@ function buildFilteredEnv(envAllowlist, contextEnv) {
|
|
|
146
180
|
* @param {string} [hook.cwd] - Relative (to root) working directory.
|
|
147
181
|
* @param {number} [hook.timeoutMs] - Kill child after this many ms.
|
|
148
182
|
* @param {string[]} [hook.envAllowlist] - Extra env keys to pass through.
|
|
183
|
+
* @param {boolean} [hook.cacheable] - Opt into the incremental result cache.
|
|
184
|
+
* @param {string[]} [hook.cacheInputs] - Input globs fingerprinting the cache key.
|
|
149
185
|
*
|
|
150
186
|
* @param {Object} context
|
|
151
187
|
* @param {string} context.root - Absolute project root.
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded observe-with-retry for transient (PROPAGATING) remote states.
|
|
3
|
+
*
|
|
4
|
+
* After an `execute` writes to a remote system, the write may not be
|
|
5
|
+
* immediately observable: package registries have eventual consistency
|
|
6
|
+
* (e.g. `npm publish` succeeds but `npm view` cannot find the new
|
|
7
|
+
* version for tens of seconds), and transient network errors happen.
|
|
8
|
+
*
|
|
9
|
+
* This module retries ONLY read-only `observe` calls while the remote
|
|
10
|
+
* state is "information-insufficient" (missing / empty / threw). The
|
|
11
|
+
* moment a concrete observation is read back, the caller classifies it
|
|
12
|
+
* (CONSISTENT / CONFLICTING / TERMINAL_MISSING). A present-but-
|
|
13
|
+
* mismatched observation (CONFLICTING) is NEVER retried: a conflict
|
|
14
|
+
* is a real, authoritative disagreement that must fail closed and be
|
|
15
|
+
* resolved by a human. This is exactly the safety semantics required by
|
|
16
|
+
* the release-skill governance (fail-closed, observe stays read-only,
|
|
17
|
+
* no execute retry, no auto-overwrite of remote state).
|
|
18
|
+
*
|
|
19
|
+
* Callers wire this into the four observe call sites (publish executeCheckpoint
|
|
20
|
+
* and the three reconcile observation points). The retry policy is fixed and
|
|
21
|
+
* not user-configurable on purpose: it never enters the frozen plan or the
|
|
22
|
+
* approval record, so changing it cannot invalidate an approved release.
|
|
23
|
+
*
|
|
24
|
+
* @module core/observe-retry
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default retry policy.
|
|
29
|
+
*
|
|
30
|
+
* The first observe call happens immediately; each subsequent retry waits
|
|
31
|
+
* the corresponding delay. With `maxAttempts: 5` and four delays the
|
|
32
|
+
* total worst-case retry window is ~150s (10+20+40+80). That is the
|
|
33
|
+
* cost of avoiding a manual reconcile loop (minutes of human time).
|
|
34
|
+
*
|
|
35
|
+
* @type {{ maxAttempts: number, delaysMs: ReadonlyArray<number> }}
|
|
36
|
+
*/
|
|
37
|
+
export const DEFAULT_OBSERVE_RETRY_POLICY = Object.freeze({
|
|
38
|
+
maxAttempts: 5,
|
|
39
|
+
delaysMs: Object.freeze([10_000, 20_000, 40_000, 80_000]),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reduce a retry policy so its total delay window fits inside a hard
|
|
44
|
+
* timeout (e.g. a marketplace action's `timeoutMs`, valid range 30s-900s).
|
|
45
|
+
*
|
|
46
|
+
* If the policy already fits, it is returned unchanged. Otherwise delays are
|
|
47
|
+
* dropped from the end (longest first) until the remaining sum fits, and
|
|
48
|
+
* `maxAttempts` is recomputed as `delays.length + 1` (one immediate
|
|
49
|
+
* observe plus one per remaining delay). Always leaves at least one attempt.
|
|
50
|
+
*
|
|
51
|
+
* @param {{ maxAttempts: number, delaysMs: ReadonlyArray<number> }} policy
|
|
52
|
+
* @param {number|null|undefined} timeoutMs - Hard ceiling in milliseconds.
|
|
53
|
+
* @returns {{ maxAttempts: number, delaysMs: number[] }}
|
|
54
|
+
*/
|
|
55
|
+
export function clampPolicyToTimeout(policy, timeoutMs) {
|
|
56
|
+
if (timeoutMs == null || typeof timeoutMs !== 'number' || timeoutMs <= 0) {
|
|
57
|
+
return policy;
|
|
58
|
+
}
|
|
59
|
+
const delays = [...policy.delaysMs];
|
|
60
|
+
let total = delays.reduce((sum, ms) => sum + ms, 0);
|
|
61
|
+
// Already fits the timeout: return the SAME policy object, unchanged.
|
|
62
|
+
if (total <= timeoutMs) {
|
|
63
|
+
return policy;
|
|
64
|
+
}
|
|
65
|
+
while (delays.length > 0 && total > timeoutMs) {
|
|
66
|
+
const dropped = delays.pop();
|
|
67
|
+
const newTotal = total - dropped;
|
|
68
|
+
if (newTotal <= timeoutMs) {
|
|
69
|
+
total = newTotal;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
total = newTotal;
|
|
73
|
+
}
|
|
74
|
+
const maxAttempts = Math.max(1, delays.length + 1);
|
|
75
|
+
return { maxAttempts, delaysMs: delays };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function defaultClock() {
|
|
79
|
+
return new Date().toISOString();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Default inter-attempt delay.
|
|
84
|
+
*
|
|
85
|
+
* Test escape hatch: when `RELEASE_SKILL_OBSERVE_RETRY_NO_WAIT=1` is set,
|
|
86
|
+
* the delay resolves immediately. This ONLY skips the wall-clock wait for
|
|
87
|
+
* spawned-CLI test sandboxes (in-process tests inject a sleep instead);
|
|
88
|
+
* attempt counts, ordering, and PROPAGATING/CONFLICTING classification are
|
|
89
|
+
* unchanged, so it cannot weaken any fail-closed decision.
|
|
90
|
+
*/
|
|
91
|
+
function defaultSleep(ms) {
|
|
92
|
+
if (process.env.RELEASE_SKILL_OBSERVE_RETRY_NO_WAIT === '1') {
|
|
93
|
+
return Promise.resolve();
|
|
94
|
+
}
|
|
95
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Decide whether an observe/verify result is "information-insufficient"
|
|
100
|
+
* (PROPAGATING) and therefore a candidate for retry.
|
|
101
|
+
*
|
|
102
|
+
* Returns `true` when the remote state is unknown or explicitly absent:
|
|
103
|
+
* - the call threw (no result at all)
|
|
104
|
+
* - the observation is missing or an empty object
|
|
105
|
+
* - an explicit absence marker is present (`exists:false`, `published:false`,
|
|
106
|
+
* `installed:false`, empty commit strings)
|
|
107
|
+
*
|
|
108
|
+
* Returns `false` when a concrete observation was read back. A present
|
|
109
|
+
* observation — even if it does NOT match the expected state — is a
|
|
110
|
+
* CONFLICTING signal, not a propagation delay, and must NOT be retried.
|
|
111
|
+
*
|
|
112
|
+
* @param {{ observation?: Object|null, error?: string|null }|null} result
|
|
113
|
+
* @returns {boolean}
|
|
114
|
+
*/
|
|
115
|
+
export function isPropagatingMissing(result) {
|
|
116
|
+
if (result == null) return true;
|
|
117
|
+
const { observation } = result;
|
|
118
|
+
if (observation == null) return true;
|
|
119
|
+
if (Object.keys(observation).length === 0) return true;
|
|
120
|
+
if (observation.exists === false) return true;
|
|
121
|
+
if (observation.remoteCommit === '') return true;
|
|
122
|
+
if (observation.commit === '') return true;
|
|
123
|
+
if (observation.published === false) return true;
|
|
124
|
+
if (observation.installed === false) return true;
|
|
125
|
+
// NOTE: a thrown observe surfaces as `error` with a null-ish observation,
|
|
126
|
+
// which is already covered by the `observation == null` / empty checks above.
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Retry a read-only `observe` (or `verify`, which is observe+match) while
|
|
132
|
+
* the remote state is information-insufficient.
|
|
133
|
+
*
|
|
134
|
+
* @param {Object} options
|
|
135
|
+
* @param {Function} options.observe - `(action, context) => Promise<result>`.
|
|
136
|
+
* The result must expose `.observation` and `.error` (the `createResult`
|
|
137
|
+
* shape). Throwing is treated as a propagating miss and retried.
|
|
138
|
+
* @param {Object} options.action - Adapter action passed through to `observe`.
|
|
139
|
+
* @param {Object} options.context - Adapter context passed through to `observe`.
|
|
140
|
+
* @param {Function} [options.isMissing] - Override for the missing check
|
|
141
|
+
* (defaults to {@link isPropagatingMissing}).
|
|
142
|
+
* @param {Object} [options.policy] - Retry policy (defaults to
|
|
143
|
+
* {@link DEFAULT_OBSERVE_RETRY_POLICY}).
|
|
144
|
+
* @param {Function} [options.clock] - `() => string` timestamp for evidence.
|
|
145
|
+
* @param {Function} [options.sleep] - `(ms) => Promise` delay (injected in tests).
|
|
146
|
+
* @param {Function} [options.onAttempt] - `async (info) => void` evidence hook,
|
|
147
|
+
* called once per attempt with `{ attempt, maxAttempts, missing, delayMs,
|
|
148
|
+
* threw, observation, error, timestamp }`.
|
|
149
|
+
* @returns {Promise<{ result: Object|null, missing: boolean, attempts: number, exhausted: boolean, threw: boolean }>}
|
|
150
|
+
* `result` is the last raw observe/verify result (or a normalized
|
|
151
|
+
* `{ observation: null, error }` when the final attempt threw). It is
|
|
152
|
+
* handed back to the caller so the caller can run its own
|
|
153
|
+
* CONSISTENT/CONFLICTING/TERMINAL_MISSING classification unchanged.
|
|
154
|
+
*/
|
|
155
|
+
export async function observeWithRetry({
|
|
156
|
+
observe,
|
|
157
|
+
action,
|
|
158
|
+
context,
|
|
159
|
+
isMissing = isPropagatingMissing,
|
|
160
|
+
policy = DEFAULT_OBSERVE_RETRY_POLICY,
|
|
161
|
+
clock = defaultClock,
|
|
162
|
+
sleep = defaultSleep,
|
|
163
|
+
onAttempt,
|
|
164
|
+
} = {}) {
|
|
165
|
+
const delays = [...(policy.delaysMs ?? [])];
|
|
166
|
+
const maxAttempts = policy.maxAttempts ?? delays.length + 1;
|
|
167
|
+
|
|
168
|
+
let lastResult = null;
|
|
169
|
+
let lastThrew = false;
|
|
170
|
+
|
|
171
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
172
|
+
let result;
|
|
173
|
+
let threw = false;
|
|
174
|
+
try {
|
|
175
|
+
result = await observe(action, context);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
threw = true;
|
|
178
|
+
lastThrew = true;
|
|
179
|
+
result = { observation: null, error: error?.message ?? String(error) };
|
|
180
|
+
}
|
|
181
|
+
lastResult = result;
|
|
182
|
+
|
|
183
|
+
const missing = isMissing(result);
|
|
184
|
+
// The delay that will actually be applied AFTER this attempt: only when
|
|
185
|
+
// the state is still missing and a delay remains. On the final attempt
|
|
186
|
+
// (or a resolved attempt) this is 0, reported truthfully.
|
|
187
|
+
const willSleep = missing && attempt < delays.length;
|
|
188
|
+
const delayMs = willSleep ? delays[attempt] : 0;
|
|
189
|
+
|
|
190
|
+
if (onAttempt) {
|
|
191
|
+
await onAttempt({
|
|
192
|
+
attempt: attempt + 1,
|
|
193
|
+
maxAttempts,
|
|
194
|
+
missing,
|
|
195
|
+
delayMs,
|
|
196
|
+
threw,
|
|
197
|
+
observation: result?.observation ?? null,
|
|
198
|
+
error: result?.error ?? null,
|
|
199
|
+
timestamp: clock(),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// A concrete observation was read back: not a propagation delay.
|
|
204
|
+
// Return immediately so the caller can classify it (including a real
|
|
205
|
+
// CONFLICTING mismatch, which must never be retried).
|
|
206
|
+
if (!missing) {
|
|
207
|
+
return { result, missing: false, attempts: attempt + 1, exhausted: false, threw: false };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Still missing: wait before the next attempt (unless this was the last).
|
|
211
|
+
if (willSleep) {
|
|
212
|
+
await sleep(delays[attempt]);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
result: lastResult,
|
|
218
|
+
missing: true,
|
|
219
|
+
attempts: maxAttempts,
|
|
220
|
+
exhausted: true,
|
|
221
|
+
threw: lastThrew,
|
|
222
|
+
};
|
|
223
|
+
}
|