@inneranimalmedia/agentsam-sdk 2.0.0 → 2.1.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.
@@ -0,0 +1,246 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { buildMerkleTree, diffTrees, readSnapshot, validateSnapshot } from '../merkle/index.js';
6
+ import { normalizePolicy } from '../merkle/policy.js';
7
+
8
+ export const DEFAULT_DEPLOY_EXCLUDES = Object.freeze([
9
+ '.agentsam/deploy-merkle',
10
+ '.wrangler',
11
+ 'coverage',
12
+ '.cache',
13
+ '.turbo',
14
+ '.env',
15
+ '.env.local',
16
+ '.env.cloudflare',
17
+ '.env.cloudflare.local',
18
+ '.env.production',
19
+ '.env.production.local',
20
+ '.env.development',
21
+ '.env.development.local',
22
+ '.env.test',
23
+ '.env.test.local',
24
+ '.env.staging',
25
+ '.env.preview',
26
+ '.env.docker',
27
+ '.dev.vars',
28
+ '.npmrc',
29
+ '.deploy-dashboard-source-fingerprint.json',
30
+ '.deploy-pipeline-stats.json',
31
+ '.deploy-r2-delta-stats.json',
32
+ '.deploy-r2-static-hashes.json',
33
+ '.deploy-route-stats.json',
34
+ '.deploy-sw-tiered-manifest.json',
35
+ '.deploy-worker-fingerprint.json',
36
+ '.deploy-worker-stats.json',
37
+ ]);
38
+
39
+ function git(root, args) {
40
+ try {
41
+ return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
42
+ } catch {
43
+ return '';
44
+ }
45
+ }
46
+
47
+ function portableRelative(root, target) {
48
+ const relative = path.relative(root, target).split(path.sep).join('/');
49
+ return relative && !relative.startsWith('../') && relative !== '..' ? relative : '';
50
+ }
51
+
52
+ function paths(root, stateDir) {
53
+ const state = path.resolve(stateDir || path.join(root, '.agentsam/deploy-merkle'));
54
+ return {
55
+ stateDir: state,
56
+ pendingSnapshot: path.join(state, 'pending.snapshot.json'),
57
+ pendingReceipt: path.join(state, 'pending.receipt.json'),
58
+ latestSnapshot: path.join(state, 'latest.snapshot.json'),
59
+ latestReceipt: path.join(state, 'latest.receipt.json'),
60
+ historyDir: path.join(state, 'history'),
61
+ };
62
+ }
63
+
64
+ async function atomicJson(filename, value) {
65
+ await fs.mkdir(path.dirname(filename), { recursive: true });
66
+ const temp = `${filename}.tmp-${randomBytes(8).toString('hex')}`;
67
+ try {
68
+ await fs.writeFile(temp, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
69
+ await fs.rename(temp, filename);
70
+ } finally {
71
+ await fs.rm(temp, { force: true }).catch(() => {});
72
+ }
73
+ }
74
+
75
+ async function loadSnapshot(input) {
76
+ if (!input) return null;
77
+ if (typeof input === 'string') return readSnapshot(input);
78
+ return validateSnapshot(input);
79
+ }
80
+
81
+ function sourceDirty(root, stateDir) {
82
+ const raw = git(root, ['status', '--porcelain=v1', '--untracked-files=all']);
83
+ if (!raw) return false;
84
+ const stateRelative = portableRelative(root, stateDir);
85
+ return raw.split('\n').some((line) => {
86
+ const candidate = line.slice(3).replace(/^"|"$/g, '').replace(/\\/g, '/');
87
+ return !(stateRelative && (candidate === stateRelative || candidate.startsWith(`${stateRelative}/`)));
88
+ });
89
+ }
90
+
91
+ function gitMetadata(root) {
92
+ return {
93
+ git_sha: git(root, ['rev-parse', 'HEAD']),
94
+ git_branch: git(root, ['rev-parse', '--abbrev-ref', 'HEAD']),
95
+ repository: git(root, ['remote', 'get-url', 'origin']),
96
+ };
97
+ }
98
+
99
+ function truncateChanges(changes, maxChangedFiles) {
100
+ const files = changes.map((change) => change.path);
101
+ if (files.length <= maxChangedFiles) return files;
102
+ return [...files.slice(0, maxChangedFiles), `__truncated__:+${files.length - maxChangedFiles}_more`];
103
+ }
104
+
105
+ export async function captureDeployReceipt({
106
+ root = '.',
107
+ project,
108
+ stateDir,
109
+ baselineSnapshot,
110
+ baselineSource,
111
+ include = [],
112
+ exclude = [],
113
+ maxChangedFiles = 100,
114
+ metadata = {},
115
+ } = {}) {
116
+ const rootPath = await fs.realpath(root);
117
+ const state = paths(rootPath, stateDir);
118
+ const dirty = sourceDirty(rootPath, state.stateDir);
119
+ const stateRelative = portableRelative(rootPath, state.stateDir);
120
+ const policy = normalizePolicy({
121
+ include,
122
+ exclude: [...new Set([
123
+ ...DEFAULT_DEPLOY_EXCLUDES,
124
+ ...(stateRelative ? [stateRelative] : []),
125
+ ...exclude,
126
+ ])],
127
+ });
128
+
129
+ await fs.mkdir(state.historyDir, { recursive: true });
130
+
131
+ let baseline = null;
132
+ let resolvedBaselineSource = baselineSource || 'none';
133
+ if (baselineSnapshot) {
134
+ try {
135
+ baseline = await loadSnapshot(baselineSnapshot);
136
+ resolvedBaselineSource = baselineSource || 'provided';
137
+ } catch {
138
+ resolvedBaselineSource = 'invalid';
139
+ }
140
+ } else {
141
+ try {
142
+ baseline = await readSnapshot(state.latestSnapshot);
143
+ resolvedBaselineSource = 'local';
144
+ } catch (error) {
145
+ if (error?.code !== 'ENOENT') resolvedBaselineSource = 'invalid';
146
+ }
147
+ }
148
+
149
+ const tree = await buildMerkleTree(rootPath, { policy });
150
+ await atomicJson(state.pendingSnapshot, tree);
151
+
152
+ let diff = null;
153
+ if (baseline) {
154
+ try {
155
+ diff = diffTrees(baseline, tree);
156
+ } catch {
157
+ baseline = null;
158
+ resolvedBaselineSource = 'invalid';
159
+ }
160
+ }
161
+
162
+ const gitInfo = gitMetadata(rootPath);
163
+ const receipt = {
164
+ format: 'agentsam-deploy-merkle',
165
+ version: 1,
166
+ engine: 'agentsam-merkle-v1',
167
+ status: 'captured',
168
+ project: project || path.basename(rootPath),
169
+ repository: gitInfo.repository || null,
170
+ git_sha: gitInfo.git_sha || null,
171
+ git_branch: gitInfo.git_branch || null,
172
+ captured_at: new Date().toISOString(),
173
+ root_hash: tree.rootHash,
174
+ previous_root_hash: baseline?.rootHash || null,
175
+ has_baseline: Boolean(baseline),
176
+ baseline_source: resolvedBaselineSource,
177
+ working_tree_dirty: dirty,
178
+ stats: tree.stats,
179
+ diff_stats: diff?.stats || null,
180
+ changed_files: diff ? truncateChanges(diff.changes, maxChangedFiles) : [],
181
+ ...metadata,
182
+ };
183
+ await atomicJson(state.pendingReceipt, receipt);
184
+ return { receipt, snapshot: tree, paths: state };
185
+ }
186
+
187
+ export async function finalizeDeployReceipt({
188
+ root = '.',
189
+ stateDir,
190
+ status,
191
+ deploymentId,
192
+ workerVersionId,
193
+ metadata = {},
194
+ } = {}) {
195
+ if (!['success', 'failed'].includes(status)) throw new Error('Deploy receipt status must be success or failed.');
196
+ const rootPath = await fs.realpath(root);
197
+ const state = paths(rootPath, stateDir);
198
+ const [pendingReceipt, pendingSnapshot] = await Promise.all([
199
+ JSON.parse(await fs.readFile(state.pendingReceipt, 'utf8')),
200
+ readSnapshot(state.pendingSnapshot),
201
+ ]).catch((error) => {
202
+ if (error?.code === 'ENOENT') throw new Error('No pending deploy receipt capture exists.');
203
+ throw error;
204
+ });
205
+ if (pendingReceipt?.format !== 'agentsam-deploy-merkle' || pendingReceipt?.status !== 'captured') {
206
+ throw new Error('Pending deploy receipt is invalid or already finalized.');
207
+ }
208
+ if (pendingReceipt.root_hash !== pendingSnapshot.rootHash) throw new Error('Pending receipt/snapshot root mismatch.');
209
+
210
+ const completed = new Date().toISOString();
211
+ const receipt = {
212
+ ...pendingReceipt,
213
+ ...metadata,
214
+ status,
215
+ completed_at: completed,
216
+ deployment_id: deploymentId || null,
217
+ worker_version_id: workerVersionId || null,
218
+ };
219
+ const stamp = completed.replace(/[-:.]/g, '').replace('Z', 'Z');
220
+ const sha = (receipt.git_sha || 'unknown').replace(/[^A-Za-z0-9._-]/g, '-');
221
+ const rootSlug = receipt.root_hash.replace(/^sha256:/, '');
222
+ const historyReceipt = path.join(state.historyDir, `${stamp}-${sha}-${rootSlug.slice(0, 12)}-${status}.receipt.json`);
223
+ await atomicJson(historyReceipt, receipt);
224
+
225
+ if (status === 'success') {
226
+ await fs.copyFile(state.pendingSnapshot, state.latestSnapshot);
227
+ await atomicJson(state.latestReceipt, receipt);
228
+ }
229
+ return { receipt, snapshot: pendingSnapshot, paths: { ...state, historyReceipt } };
230
+ }
231
+
232
+ export async function showLatestDeployReceipt({ root = '.', stateDir } = {}) {
233
+ const rootPath = await fs.realpath(root);
234
+ const state = paths(rootPath, stateDir);
235
+ try {
236
+ return JSON.parse(await fs.readFile(state.latestReceipt, 'utf8'));
237
+ } catch (error) {
238
+ if (error?.code === 'ENOENT') return null;
239
+ throw error;
240
+ }
241
+ }
242
+
243
+ export const captureCheckpoint = captureDeployReceipt;
244
+ export async function promoteCheckpoint(options = {}) {
245
+ return finalizeDeployReceipt({ ...options, status: 'success' });
246
+ }
@@ -0,0 +1,91 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { execFileSync, spawnSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import {
9
+ captureDeployReceipt,
10
+ finalizeDeployReceipt,
11
+ showLatestDeployReceipt,
12
+ } from '../src/lib/deploy-receipt/index.js';
13
+
14
+ const cli = fileURLToPath(new URL('../src/cli.js', import.meta.url));
15
+
16
+ async function fixture(t) {
17
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'agentsam-deploy-receipt-'));
18
+ t.after(() => fs.rm(root, { recursive: true, force: true }));
19
+ execFileSync('git', ['init', '-q', root]);
20
+ execFileSync('git', ['-C', root, 'config', 'user.email', 'receipt-test@example.invalid']);
21
+ execFileSync('git', ['-C', root, 'config', 'user.name', 'Receipt Test']);
22
+ await fs.mkdir(path.join(root, 'src'), { recursive: true });
23
+ await fs.writeFile(path.join(root, 'src/app.js'), 'export const value = 1;\n');
24
+ execFileSync('git', ['-C', root, 'add', '.']);
25
+ execFileSync('git', ['-C', root, 'commit', '-qm', 'fixture']);
26
+ return root;
27
+ }
28
+
29
+ function run(args, cwd) {
30
+ return spawnSync(process.execPath, [cli, 'deploy-receipt', ...args], { cwd, encoding: 'utf8', timeout: 15000 });
31
+ }
32
+
33
+ test('successful finalize advances baseline while failure preserves the last trusted tree', async (t) => {
34
+ const root = await fixture(t);
35
+ const first = await captureDeployReceipt({ root, project: 'fixture' });
36
+ assert.equal(first.receipt.has_baseline, false);
37
+ assert.equal(first.receipt.baseline_source, 'none');
38
+ assert.equal(first.receipt.working_tree_dirty, false);
39
+ assert.ok(!first.snapshot.entries.some((entry) => entry.path.startsWith('.agentsam/deploy-merkle')));
40
+
41
+ const promoted = await finalizeDeployReceipt({ root, status: 'success', deploymentId: 'dep_1' });
42
+ assert.equal(promoted.receipt.status, 'success');
43
+ assert.equal(promoted.receipt.deployment_id, 'dep_1');
44
+ assert.equal((await showLatestDeployReceipt({ root })).root_hash, first.receipt.root_hash);
45
+
46
+ const clean = await captureDeployReceipt({ root, project: 'fixture' });
47
+ assert.equal(clean.receipt.has_baseline, true);
48
+ assert.equal(clean.receipt.baseline_source, 'local');
49
+ assert.deepEqual(clean.receipt.diff_stats, { unchanged: 1, modified: 0, added: 0, removed: 0 });
50
+ assert.deepEqual(clean.receipt.changed_files, []);
51
+ assert.equal(clean.receipt.root_hash, first.receipt.root_hash);
52
+ assert.equal(clean.receipt.working_tree_dirty, false, 'runtime receipt state must not make the source tree dirty');
53
+
54
+ await fs.writeFile(path.join(root, 'src/app.js'), 'export const value = 2;\n');
55
+ const changed = await captureDeployReceipt({ root, project: 'fixture' });
56
+ assert.equal(changed.receipt.working_tree_dirty, true);
57
+ assert.deepEqual(changed.receipt.diff_stats, { unchanged: 0, modified: 1, added: 0, removed: 0 });
58
+ assert.deepEqual(changed.receipt.changed_files, ['src/app.js']);
59
+ assert.notEqual(changed.receipt.root_hash, first.receipt.root_hash);
60
+
61
+ await finalizeDeployReceipt({ root, status: 'failed', deploymentId: 'dep_2' });
62
+ assert.equal((await showLatestDeployReceipt({ root })).root_hash, first.receipt.root_hash, 'failed run must not advance baseline');
63
+
64
+ const retry = await captureDeployReceipt({ root, project: 'fixture' });
65
+ assert.deepEqual(retry.receipt.changed_files, ['src/app.js']);
66
+ await finalizeDeployReceipt({ root, status: 'success', deploymentId: 'dep_3' });
67
+ const settled = await captureDeployReceipt({ root, project: 'fixture' });
68
+ assert.deepEqual(settled.receipt.diff_stats, { unchanged: 1, modified: 0, added: 0, removed: 0 });
69
+ assert.deepEqual(settled.receipt.changed_files, []);
70
+ });
71
+
72
+ test('deploy-receipt CLI emits machine-readable capture/finalize/show receipts', async (t) => {
73
+ const root = await fixture(t);
74
+ const capture = run(['capture', '.', '--project', 'cli-fixture', '--json'], root);
75
+ assert.equal(capture.status, 0, capture.stderr);
76
+ const captured = JSON.parse(capture.stdout);
77
+ assert.equal(captured.format, 'agentsam-deploy-merkle');
78
+ assert.equal(captured.project, 'cli-fixture');
79
+ assert.equal(captured.status, 'captured');
80
+
81
+ const success = run(['success', '.', '--deployment-id', 'dep_cli', '--worker-version', 'worker_v1', '--json'], root);
82
+ assert.equal(success.status, 0, success.stderr);
83
+ const finalized = JSON.parse(success.stdout);
84
+ assert.equal(finalized.status, 'success');
85
+ assert.equal(finalized.deployment_id, 'dep_cli');
86
+ assert.equal(finalized.worker_version_id, 'worker_v1');
87
+
88
+ const show = run(['show', '.', '--json'], root);
89
+ assert.equal(show.status, 0, show.stderr);
90
+ assert.equal(JSON.parse(show.stdout).root_hash, finalized.root_hash);
91
+ });