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.
Files changed (59) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/.kimi-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +53 -0
  6. package/INSTALL.md +4 -4
  7. package/INSTALL.zh-CN.md +4 -4
  8. package/README.md +18 -33
  9. package/README.zh-CN.md +17 -23
  10. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  11. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  12. package/adapters/claude/bin/release-skill.bundle.mjs +2740 -1844
  13. package/adapters/claude/schemas/.render-manifest.json +8 -8
  14. package/adapters/claude/schemas/approval-record.schema.json +1 -1
  15. package/adapters/claude/schemas/release-plan.schema.json +6 -2
  16. package/adapters/claude/schemas/release-project.schema.json +14 -0
  17. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  18. package/adapters/codex/bin/release-skill.bundle.mjs +2740 -1844
  19. package/adapters/codex/schemas/.render-manifest.json +8 -8
  20. package/adapters/codex/schemas/approval-record.schema.json +1 -1
  21. package/adapters/codex/schemas/release-plan.schema.json +6 -2
  22. package/adapters/codex/schemas/release-project.schema.json +14 -0
  23. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  24. package/adapters/kimi/bin/release-skill.bundle.mjs +2740 -1844
  25. package/adapters/kimi/schemas/.render-manifest.json +8 -8
  26. package/adapters/kimi/schemas/approval-record.schema.json +1 -1
  27. package/adapters/kimi/schemas/release-plan.schema.json +6 -2
  28. package/adapters/kimi/schemas/release-project.schema.json +14 -0
  29. package/bin/release-skill-cli.mjs +3 -0
  30. package/bin/release-skill.bundle.mjs +2740 -1844
  31. package/package.json +8 -2
  32. package/references/.render-manifest.json +8 -8
  33. package/references/01-state-machine.md +5 -5
  34. package/references/02-project-config.md +1 -1
  35. package/references/05-evidence-and-errors.md +1 -1
  36. package/references/06-adapter-contract.md +41 -1
  37. package/schemas/.render-manifest.json +8 -8
  38. package/schemas/approval-record.schema.json +1 -1
  39. package/schemas/release-plan.schema.json +6 -2
  40. package/schemas/release-project.schema.json +14 -0
  41. package/scripts/sync-public-files.mjs +462 -0
  42. package/src/adapters/contract.mjs +60 -0
  43. package/src/adapters/plugin-marketplace.mjs +289 -730
  44. package/src/commands/prepare.mjs +195 -182
  45. package/src/commands/publish.mjs +438 -122
  46. package/src/commands/reconcile.mjs +369 -191
  47. package/src/commands/verify.mjs +13 -2
  48. package/src/core/approval.mjs +72 -45
  49. package/src/core/baseline.mjs +16 -0
  50. package/src/core/checkpoints.mjs +143 -0
  51. package/src/core/evidence.mjs +30 -3
  52. package/src/core/hook-cache.mjs +254 -0
  53. package/src/core/hooks.mjs +37 -1
  54. package/src/core/observe-retry.mjs +223 -0
  55. package/src/core/plan.mjs +162 -253
  56. package/src/platforms/kimi.mjs +514 -0
  57. package/src/platforms/registry.mjs +393 -0
  58. package/src/producers/build-adapters.mjs +14 -22
  59. 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
+ }
@@ -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
+ }