Skip to content

Commit 9696d10

Browse files
committed
refactor: scoped state writes preserve untouched entries
## ELI5 **Problem.** Even when you ran a *scoped* push — say `npm run push -- <env> assistants/foo.md` to update one assistant — the engine rewrote the **entire** state file. Any pre-existing drift in unrelated state entries (UUIDs from earlier sessions, untracked local files, etc.) swept into the focused commit. Reviewers couldn't tell from the state-file diff "what did this push actually change?" and the state file became a pile of side effects accumulated across sessions instead of a precise record of intent. **What this fix does.** During a push, the engine tracks which `resourceId`s it actually mutated (a per-section `Set<string>`). At end-of-run, for **scoped pushes only**, it loads the on-disk state fresh, replaces only the touched entries with the in-memory version, and leaves everything else alone. Full pushes (no scope) still write wholesale (existing behavior). Credentials are always replaced because bootstrap pull populates them every push regardless. This depends on Stack F's `ResourceState` because we need per-entry metadata to distinguish "stale" from "just-not-touched." **Outcome you'll notice.** A one-file `npm run push` produces a one-file diff in the state file — same scope as the resource change. Reviewers can read the state diff and tell "this push updated assistant `foo`, here's its new hash" cleanly. Pre-existing drift elsewhere in state stays where it is until you explicitly address it. --- When push is scoped to specific paths, only update state entries for the resources actually touched. A surgical push of two files used to rewrite the entire state file, sweeping in pre-existing drift from earlier pushes (improvements.md #15) and producing noisy diffs that hide the actual scope of the change. Files: - src/state-merge.ts (NEW): mergeScoped(disk, inMemory, touched). For each section, replace only touched.X resourceIds with the in-memory version; leave the rest of disk's section as-is. Credentials are always replaced wholesale (bootstrap pull populates them on every push). Pure data, no I/O — safe to test directly. - src/push.ts: TouchedSets tracker. Each upsertState call site records the resourceId. End-of-run, partial pushes call mergeScoped(loadState(), state, touched) before saveState; full pushes save wholesale (existing behavior). - tests/state-merge.test.ts: replace-only-touched, leave-untouched, drift in untouched stays, credentials always replaced. Closes improvements.md #15. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent e3ca040 commit 9696d10

4 files changed

Lines changed: 273 additions & 2 deletions

File tree

improvements.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ you which stack PR closes the row.**
6666
| 12 | State file accumulates UUIDs without source files | Silent gitops drift | None | Partial |
6767
| 13 | `.agent/` and `.claude/handoffs/` not gitignored | `git add -A` sweeps PII handoff scratch | None | RESOLVED 2026-04-30 (Stack A) |
6868
| 14 | Multi-file push undocumented | Discoverability | None | RESOLVED 2026-04-30 (Stack A) |
69-
| 15 | Scoped push rewrites entire state file | Pre-existing drift sweeps into focused commits | #4 | Open (Stack J planned) |
69+
| 15 | Scoped push rewrites entire state file | Pre-existing drift sweeps into focused commits | #4 | RESOLVED 2026-04-30 (Stack J) |
7070
| 16 | No CLI runner for simulation suites | Engine pushes them, can't run them | None | RESOLVED 2026-04-30 (Stack E) |
7171
| 17 | State file key-order churn produces noisy diffs | Reorderings hide real changes | None | RESOLVED 2026-04-30 (Stack B) |
7272
| 18 | Structured-output `name` capped at 40 chars (no warning) | Push fails partway after partial application | None | RESOLVED 2026-04-30 (Stack D) |

src/push.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { summarizeFindings, validateResources } from "./validate.ts";
1616
import { checkDriftForUpdate } from "./drift.ts";
1717
import { writeSnapshot } from "./snapshot.ts";
18+
import { mergeScoped } from "./state-merge.ts";
1819

1920
// Map a resource label to its state-file key. Used for snapshotting (Stack H)
2021
// — snapshot directories are keyed by the same names the state file uses.
@@ -777,6 +778,40 @@ function filterResourcesByPaths<T>(
777778
return resources.filter((r) => matchingIds.has(r.resourceId));
778779
}
779780

781+
// Stack J — track which resourceIds were actually written during this apply.
782+
// On scoped push, the end-of-run save merges only these entries back into
783+
// the on-disk state, leaving untouched entries alone. Without this, a scoped
784+
// push (`npm run push -- <env> assistants/foo.md`) sweeps in any pre-existing
785+
// drift across the entire state file (improvements.md #15).
786+
interface TouchedSets {
787+
tools: Set<string>;
788+
structuredOutputs: Set<string>;
789+
assistants: Set<string>;
790+
squads: Set<string>;
791+
personalities: Set<string>;
792+
scenarios: Set<string>;
793+
simulations: Set<string>;
794+
simulationSuites: Set<string>;
795+
evals: Set<string>;
796+
// refreshed on every push (bootstrap pull populates them)
797+
credentials: Set<string>;
798+
}
799+
800+
function emptyTouchedSets(): TouchedSets {
801+
return {
802+
tools: new Set(),
803+
structuredOutputs: new Set(),
804+
assistants: new Set(),
805+
squads: new Set(),
806+
personalities: new Set(),
807+
scenarios: new Set(),
808+
simulations: new Set(),
809+
simulationSuites: new Set(),
810+
evals: new Set(),
811+
credentials: new Set(),
812+
};
813+
}
814+
780815
// ─────────────────────────────────────────────────────────────────────────────
781816
// Auto-Dependency Resolution
782817
// When pushing a resource with missing dependencies, auto-apply them first
@@ -966,6 +1001,10 @@ async function main(): Promise<void> {
9661001
// Load current state (needed for reference resolution even in partial apply)
9671002
let state = loadState();
9681003

1004+
// Stack J — track which resourceIds we actually mutate so the end-of-run
1005+
// save can merge into existing on-disk state instead of rewriting wholesale.
1006+
const touched: TouchedSets = emptyTouchedSets();
1007+
9691008
// Track what was applied for summary
9701009
const applied: Record<ResourceType, number> = {
9711010
tools: 0,
@@ -1203,6 +1242,7 @@ async function main(): Promise<void> {
12031242
uuid,
12041243
lastPushedHash: hashPayload(tool.data),
12051244
});
1245+
touched.tools.add(tool.resourceId);
12061246
applied.tools++;
12071247
} catch (error) {
12081248
console.error(formatApiError(tool.resourceId, error));
@@ -1221,6 +1261,7 @@ async function main(): Promise<void> {
12211261
uuid,
12221262
lastPushedHash: hashPayload(output.data),
12231263
});
1264+
touched.structuredOutputs.add(output.resourceId);
12241265
applied.structuredOutputs++;
12251266
} catch (error) {
12261267
console.error(formatApiError(output.resourceId, error));
@@ -1252,6 +1293,7 @@ async function main(): Promise<void> {
12521293
uuid,
12531294
lastPushedHash: hashPayload(assistant.data),
12541295
});
1296+
touched.assistants.add(assistant.resourceId);
12551297
applied.assistants++;
12561298
} catch (error) {
12571299
console.error(formatApiError(assistant.resourceId, error));
@@ -1277,6 +1319,7 @@ async function main(): Promise<void> {
12771319
uuid,
12781320
lastPushedHash: hashPayload(squad.data),
12791321
});
1322+
touched.squads.add(squad.resourceId);
12801323
applied.squads++;
12811324
} catch (error) {
12821325
console.error(formatApiError(squad.resourceId, error));
@@ -1295,6 +1338,7 @@ async function main(): Promise<void> {
12951338
uuid,
12961339
lastPushedHash: hashPayload(personality.data),
12971340
});
1341+
touched.personalities.add(personality.resourceId);
12981342
applied.personalities++;
12991343
} catch (error) {
13001344
console.error(formatApiError(personality.resourceId, error));
@@ -1313,6 +1357,7 @@ async function main(): Promise<void> {
13131357
uuid,
13141358
lastPushedHash: hashPayload(scenario.data),
13151359
});
1360+
touched.scenarios.add(scenario.resourceId);
13161361
applied.scenarios++;
13171362
} catch (error) {
13181363
console.error(formatApiError(scenario.resourceId, error));
@@ -1331,6 +1376,7 @@ async function main(): Promise<void> {
13311376
uuid,
13321377
lastPushedHash: hashPayload(simulation.data),
13331378
});
1379+
touched.simulations.add(simulation.resourceId);
13341380
applied.simulations++;
13351381
} catch (error) {
13361382
console.error(formatApiError(simulation.resourceId, error));
@@ -1349,6 +1395,7 @@ async function main(): Promise<void> {
13491395
uuid,
13501396
lastPushedHash: hashPayload(suite.data),
13511397
});
1398+
touched.simulationSuites.add(suite.resourceId);
13521399
applied.simulationSuites++;
13531400
} catch (error) {
13541401
console.error(formatApiError(suite.resourceId, error));
@@ -1366,6 +1413,7 @@ async function main(): Promise<void> {
13661413
uuid,
13671414
lastPushedHash: hashPayload(evalResource.data),
13681415
});
1416+
touched.evals.add(evalResource.resourceId);
13691417
applied.evals++;
13701418
} catch (error) {
13711419
console.error(formatApiError(evalResource.resourceId, error));
@@ -1455,7 +1503,14 @@ async function main(): Promise<void> {
14551503
);
14561504
} else {
14571505
try {
1458-
await saveState(state);
1506+
// Stack J — for scoped pushes, only persist entries we actually
1507+
// mutated. Re-load disk state and merge our touched entries on top
1508+
// so unrelated drift in untouched entries is left alone. A bare
1509+
// (non-partial) push falls through to the wholesale save.
1510+
const stateToWrite = partial
1511+
? mergeScoped(loadState(), state, touched)
1512+
: state;
1513+
await saveState(stateToWrite);
14591514
} catch (saveError) {
14601515
console.error(
14611516
"\n⚠️ Failed to persist state file after apply:",

src/state-merge.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Stack J — scoped state writes.
2+
//
3+
// On a scoped push (`npm run push -- <env> assistants/foo.md`), the engine
4+
// previously rewrote the entire state file even when only one assistant
5+
// was applied. Pre-existing dashboard drift in unrelated state entries
6+
// would silently sweep into the commit-able diff.
7+
//
8+
// `mergeScoped` produces a new state object where:
9+
// - Every entry NOT in `touched` is copied from `onDisk` (untouched —
10+
// leaves pre-existing drift alone).
11+
// - Every entry IN `touched` is taken from `inMemory` (the live state
12+
// after the push run).
13+
//
14+
// Untouched-on-platform entries that no longer have a local file are
15+
// preserved AS-IS — they're outside the scope of this push and will be
16+
// reconciled by a subsequent full push or pull.
17+
//
18+
// Credentials always refresh from `inMemory` because bootstrap pull
19+
// rewrites them whether or not a partial filter targeted them.
20+
21+
import type { ResourceState, StateFile } from "./types.ts";
22+
23+
export interface TouchedSets {
24+
tools: Set<string>;
25+
structuredOutputs: Set<string>;
26+
assistants: Set<string>;
27+
squads: Set<string>;
28+
personalities: Set<string>;
29+
scenarios: Set<string>;
30+
simulations: Set<string>;
31+
simulationSuites: Set<string>;
32+
evals: Set<string>;
33+
credentials: Set<string>;
34+
}
35+
36+
const SECTIONS: Array<keyof StateFile> = [
37+
"tools",
38+
"structuredOutputs",
39+
"assistants",
40+
"squads",
41+
"personalities",
42+
"scenarios",
43+
"simulations",
44+
"simulationSuites",
45+
"evals",
46+
];
47+
48+
export function mergeScoped(
49+
onDisk: StateFile,
50+
inMemory: StateFile,
51+
touched: TouchedSets,
52+
): StateFile {
53+
const merged: StateFile = {
54+
credentials: { ...inMemory.credentials }, // always refresh
55+
tools: {},
56+
structuredOutputs: {},
57+
assistants: {},
58+
squads: {},
59+
personalities: {},
60+
scenarios: {},
61+
simulations: {},
62+
simulationSuites: {},
63+
evals: {},
64+
};
65+
66+
for (const section of SECTIONS) {
67+
const touchedIds = touched[section];
68+
const out: Record<string, ResourceState> = {};
69+
70+
// Copy all on-disk entries that weren't touched (leave them alone).
71+
for (const [id, entry] of Object.entries(onDisk[section])) {
72+
if (!touchedIds.has(id)) {
73+
out[id] = entry;
74+
}
75+
}
76+
// Overlay in-memory entries that WERE touched.
77+
for (const id of touchedIds) {
78+
const entry = inMemory[section][id];
79+
if (entry) out[id] = entry;
80+
}
81+
82+
merged[section] = out;
83+
}
84+
85+
return merged;
86+
}

tests/state-merge.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import { mergeScoped, type TouchedSets } from "../src/state-merge.ts";
4+
import type { StateFile } from "../src/types.ts";
5+
6+
// Stack J — scoped state-merge coverage. The plan's #15 fix: a scoped push
7+
// must NOT sweep pre-existing drift (state entries unrelated to the touched
8+
// resources) into the commit-able state diff.
9+
10+
function emptyState(): StateFile {
11+
return {
12+
credentials: {},
13+
assistants: {},
14+
structuredOutputs: {},
15+
tools: {},
16+
squads: {},
17+
personalities: {},
18+
scenarios: {},
19+
simulations: {},
20+
simulationSuites: {},
21+
evals: {},
22+
};
23+
}
24+
25+
function emptyTouched(): TouchedSets {
26+
return {
27+
tools: new Set(),
28+
structuredOutputs: new Set(),
29+
assistants: new Set(),
30+
squads: new Set(),
31+
personalities: new Set(),
32+
scenarios: new Set(),
33+
simulations: new Set(),
34+
simulationSuites: new Set(),
35+
evals: new Set(),
36+
credentials: new Set(),
37+
};
38+
}
39+
40+
test("mergeScoped: untouched entries copied from on-disk state", () => {
41+
const onDisk = emptyState();
42+
onDisk.assistants["unrelated-1"] = { uuid: "u-1", lastPulledHash: "h-1" };
43+
onDisk.assistants["unrelated-2"] = { uuid: "u-2", lastPulledHash: "h-2" };
44+
45+
const inMemory = emptyState();
46+
// In-memory state has unrelated-1 with a different hash (drift) and a
47+
// newly-touched assistant. mergeScoped should copy unrelated-1 from disk
48+
// (untouched), and only take touched-agent from in-memory.
49+
inMemory.assistants["unrelated-1"] = { uuid: "u-1", lastPulledHash: "h-X" };
50+
inMemory.assistants["touched-agent"] = {
51+
uuid: "u-3",
52+
lastPushedHash: "fresh",
53+
};
54+
55+
const touched = emptyTouched();
56+
touched.assistants.add("touched-agent");
57+
58+
const merged = mergeScoped(onDisk, inMemory, touched);
59+
assert.equal(merged.assistants["unrelated-1"]!.lastPulledHash, "h-1");
60+
assert.equal(merged.assistants["unrelated-2"]!.lastPulledHash, "h-2");
61+
assert.equal(merged.assistants["touched-agent"]!.lastPushedHash, "fresh");
62+
});
63+
64+
test("mergeScoped: touched entries take in-memory version", () => {
65+
const onDisk = emptyState();
66+
onDisk.assistants["agent-a"] = { uuid: "u-1", lastPulledHash: "old" };
67+
68+
const inMemory = emptyState();
69+
inMemory.assistants["agent-a"] = {
70+
uuid: "u-1",
71+
lastPulledHash: "old",
72+
lastPushedHash: "new",
73+
};
74+
75+
const touched = emptyTouched();
76+
touched.assistants.add("agent-a");
77+
78+
const merged = mergeScoped(onDisk, inMemory, touched);
79+
assert.equal(merged.assistants["agent-a"]!.lastPushedHash, "new");
80+
});
81+
82+
test("mergeScoped: credentials always refreshed from in-memory", () => {
83+
const onDisk = emptyState();
84+
onDisk.credentials["openai"] = { uuid: "old-cred-uuid" };
85+
86+
const inMemory = emptyState();
87+
inMemory.credentials["openai"] = { uuid: "new-cred-uuid" };
88+
// Bootstrap pull also added a new credential
89+
inMemory.credentials["langfuse"] = { uuid: "lang-cred-uuid" };
90+
91+
const touched = emptyTouched(); // credentials are NOT explicitly touched
92+
93+
const merged = mergeScoped(onDisk, inMemory, touched);
94+
assert.equal(merged.credentials["openai"]!.uuid, "new-cred-uuid");
95+
assert.equal(merged.credentials["langfuse"]!.uuid, "lang-cred-uuid");
96+
});
97+
98+
test("mergeScoped: empty touched preserves all on-disk state", () => {
99+
const onDisk = emptyState();
100+
onDisk.assistants["a"] = { uuid: "u-a" };
101+
onDisk.tools["t"] = { uuid: "u-t" };
102+
103+
const inMemory = emptyState(); // empty (e.g., scoped to a missing path)
104+
105+
const touched = emptyTouched();
106+
107+
const merged = mergeScoped(onDisk, inMemory, touched);
108+
assert.deepEqual(merged.assistants, { a: { uuid: "u-a" } });
109+
assert.deepEqual(merged.tools, { t: { uuid: "u-t" } });
110+
});
111+
112+
test("mergeScoped: cross-section isolation (touched assistants do NOT affect tools section)", () => {
113+
const onDisk = emptyState();
114+
onDisk.tools["unrelated-tool"] = { uuid: "u-tool", lastPulledHash: "tool-hash" };
115+
onDisk.assistants["agent-a"] = { uuid: "u-old" };
116+
117+
const inMemory = emptyState();
118+
inMemory.assistants["agent-a"] = { uuid: "u-old", lastPushedHash: "fresh" };
119+
// In-memory has an unrelated drift in tools section that should NOT bleed in
120+
inMemory.tools["unrelated-tool"] = { uuid: "u-tool", lastPulledHash: "drifted" };
121+
122+
const touched = emptyTouched();
123+
touched.assistants.add("agent-a"); // ONLY assistants touched
124+
125+
const merged = mergeScoped(onDisk, inMemory, touched);
126+
// tools section preserved from disk
127+
assert.equal(merged.tools["unrelated-tool"]!.lastPulledHash, "tool-hash");
128+
// assistants section: touched entry takes in-memory
129+
assert.equal(merged.assistants["agent-a"]!.lastPushedHash, "fresh");
130+
});

0 commit comments

Comments
 (0)