Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion improvements.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ you which stack PR closes the row.**
| 1 | `push` drift detection | Prevent silent overwrites of dashboard edits | #4 | Open (Stack G planned) |
| 2 | `apply` same-file conflict | `apply` drops concurrent same-file dashboard edits | #4 | Open (Stack G planned) |
| 3 | Rollback | Current undo can clobber newer live changes | #4, #5 | Open (Stack H planned) |
| 4 | State schema content hashes | Architectural unlock for #1, #2, #3, #6, #7 | None | Open (Stack F planned) |
| 4 | State schema content hashes | Architectural unlock for #1, #2, #3, #6, #7 | None | RESOLVED 2026-04-30 (Stack F) |
| 5 | `push --dry-run` | Cheapest operator-safety win | None | RESOLVED 2026-04-30 (Stack C) |
| 6 | API-level optimistic concurrency | Server-side conflict rejection | Platform | Deferred (Stack I, gated) |
| 7 | Voice edits drop pronunciation-dictionary attachments | Silent regression on Cartesia + 11labs voice edits | #4 | Open (Stack G planned) |
Expand Down
7 changes: 3 additions & 4 deletions src/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,8 @@ function resolveTarget(
resourceType: ResourceType,
): string {
if (resourceType === "squad") {
const squads =
(state as StateFile & { squads?: Record<string, string> }).squads || {};
const uuid = squads[target];
const squads = state.squads || {};
const uuid = squads[target]?.uuid;
if (!uuid) {
console.error(`❌ Squad not found: ${target}`);
console.error(" Available squads:");
Expand All @@ -308,7 +307,7 @@ function resolveTarget(
}
return uuid;
} else {
const uuid = state.assistants[target];
const uuid = state.assistants[target]?.uuid;
if (!uuid) {
console.error(`❌ Assistant not found: ${target}`);
console.error(" Available assistants:");
Expand Down
20 changes: 11 additions & 9 deletions src/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,16 +111,18 @@ async function main(): Promise<void> {
}

const state = loadState();
// Stack F: state values are ResourceState objects, not bare UUIDs. Extract
// each .uuid for the orphan-detection set.
const stateIds = new Set([
...Object.values(state.assistants),
...Object.values(state.tools),
...Object.values(state.structuredOutputs),
...Object.values(state.squads),
...Object.values(state.personalities),
...Object.values(state.scenarios),
...Object.values(state.simulations),
...Object.values(state.simulationSuites),
...Object.values(state.evals),
...Object.values(state.assistants).map((e) => e.uuid),
...Object.values(state.tools).map((e) => e.uuid),
...Object.values(state.structuredOutputs).map((e) => e.uuid),
...Object.values(state.squads).map((e) => e.uuid),
...Object.values(state.personalities).map((e) => e.uuid),
...Object.values(state.scenarios).map((e) => e.uuid),
...Object.values(state.simulations).map((e) => e.uuid),
...Object.values(state.simulationSuites).map((e) => e.uuid),
...Object.values(state.evals).map((e) => e.uuid),
]);

// A state file with zero tracked resources is almost always a fresh clone,
Expand Down
8 changes: 4 additions & 4 deletions src/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,16 @@ import type { StateFile } from "./types.ts";

export function credentialReverseMap(state: StateFile): Map<string, string> {
const map = new Map<string, string>();
for (const [name, uuid] of Object.entries(state.credentials)) {
map.set(uuid, name);
for (const [name, entry] of Object.entries(state.credentials)) {
map.set(entry.uuid, name);
}
return map;
}

export function credentialForwardMap(state: StateFile): Map<string, string> {
const map = new Map<string, string>();
for (const [name, uuid] of Object.entries(state.credentials)) {
map.set(name, uuid);
for (const [name, entry] of Object.entries(state.credentials)) {
map.set(name, entry.uuid);
}
return map;
}
Expand Down
7 changes: 4 additions & 3 deletions src/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { FORCE_DELETE } from "./config.ts";
import { extractReferencedIds } from "./resolver.ts";
import type {
ResourceFile,
ResourceState,
StateFile,
LoadedResources,
OrphanedResource,
Expand All @@ -15,13 +16,13 @@ import type {

export function findOrphanedResources(
loadedResourceIds: string[],
stateResourceIds: Record<string, string>
stateResourceIds: Record<string, ResourceState>
): OrphanedResource[] {
const orphaned: OrphanedResource[] = [];

for (const [resourceId, uuid] of Object.entries(stateResourceIds)) {
for (const [resourceId, entry] of Object.entries(stateResourceIds)) {
if (!loadedResourceIds.includes(resourceId)) {
orphaned.push({ resourceId, uuid });
orphaned.push({ resourceId, uuid: entry.uuid });
}
}

Expand Down
23 changes: 15 additions & 8 deletions src/eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,10 +250,13 @@ function loadVariables(config: EvalConfig): Record<string, unknown> | undefined

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

function resolveId(id: string, stateSection: Record<string, string>): string {
function resolveId(
id: string,
stateSection: Record<string, { uuid: string } | undefined>,
): string {
const clean = id.split("##")[0]?.trim() ?? "";
if (UUID_RE.test(clean)) return clean;
return stateSection[clean] ?? clean;
return stateSection[clean]?.uuid ?? clean;
}

function resolveAssistantConfig(config: Record<string, unknown>, state: StateFile): Record<string, unknown> {
Expand All @@ -278,7 +281,9 @@ function resolveAssistantConfig(config: Record<string, unknown>, state: StateFil
}
}
// Resolve credentials
const credMap = new Map(Object.entries(state.credentials));
const credMap = new Map(
Object.entries(state.credentials).map(([slug, rs]) => [slug, rs.uuid]),
);
if (credMap.size > 0) return deepReplace(resolved, credMap) as Record<string, unknown>;
return resolved;
}
Expand Down Expand Up @@ -314,7 +319,9 @@ async function resolveSquadConfig(config: Record<string, unknown>, state: StateF
}
}
}
const credMap = new Map(Object.entries(state.credentials));
const credMap = new Map(
Object.entries(state.credentials).map(([slug, rs]) => [slug, rs.uuid]),
);
if (credMap.size > 0) return deepReplace(resolved, credMap) as Record<string, unknown>;
return resolved;
}
Expand Down Expand Up @@ -344,9 +351,9 @@ function loadEvals(state: StateFile, filter?: string): EvalDefinition[] {
const evalState = state.evals ?? {};
const evals: EvalDefinition[] = [];

for (const [resourceId, uuid] of Object.entries(evalState)) {
for (const [resourceId, entry] of Object.entries(evalState)) {
if (filter && !resourceId.toLowerCase().includes(filter.toLowerCase())) continue;
evals.push({ resourceId, evalId: uuid, name: resourceId });
evals.push({ resourceId, evalId: entry.uuid, name: resourceId });
}

return evals;
Expand Down Expand Up @@ -443,7 +450,7 @@ async function main(): Promise<void> {

if (config.squadName) {
if (config.useStored) {
const squadId = state.squads[config.squadName];
const squadId = state.squads[config.squadName]?.uuid;
if (!squadId) {
console.error(`❌ Squad not found in state: ${config.squadName}`);
console.error(" Available: " + Object.keys(state.squads).join(", "));
Expand All @@ -466,7 +473,7 @@ async function main(): Promise<void> {
}
} else {
if (config.useStored) {
const assistantId = state.assistants[config.assistantName!];
const assistantId = state.assistants[config.assistantName!]?.uuid;
if (!assistantId) {
console.error(`❌ Assistant not found in state: ${config.assistantName}`);
process.exit(1);
Expand Down
46 changes: 29 additions & 17 deletions src/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import {
loadIgnorePatterns,
matchesIgnore,
} from "./config.ts";
import { loadState, saveState } from "./state.ts";
import { hashPayload, loadState, saveState, upsertState } from "./state.ts";
import { credentialReverseMap, replaceCredentialRefs } from "./credentials.ts";
import type { StateFile, ResourceType } from "./types.ts";
import type { ResourceState, StateFile, ResourceType } from "./types.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Types
Expand Down Expand Up @@ -221,11 +221,11 @@ async function pullCredentials(state: StateFile): Promise<void> {
const credentials = await fetchCredentials();
console.log(` Found ${credentials.length} credentials in Vapi`);

const newSection: Record<string, string> = {};
const newSection: Record<string, ResourceState> = {};
// Build reverse map from existing state to preserve slug stability
const existingReverse = new Map<string, string>();
for (const [slug, uuid] of Object.entries(state.credentials)) {
existingReverse.set(uuid, slug);
for (const [slug, entry] of Object.entries(state.credentials)) {
existingReverse.set(entry.uuid, slug);
}

for (const cred of credentials) {
Expand All @@ -234,7 +234,12 @@ async function pullCredentials(state: StateFile): Promise<void> {
if (!slug) {
slug = credentialSlug(cred);
}
newSection[slug] = cred.id;
// Preserve existing hash metadata if the slug+uuid pair survives.
const prior = state.credentials[slug];
newSection[slug] =
prior && prior.uuid === cred.id
? prior
: { uuid: cred.id, lastPulledAt: new Date().toISOString() };
console.log(` 🔑 ${slug} -> ${cred.id}`);
}

Expand Down Expand Up @@ -327,12 +332,12 @@ function findExistingResourceId(
}

function removeUuidMappings(
stateSection: Record<string, string>,
stateSection: Record<string, ResourceState>,
uuid: string,
keepResourceId?: string,
): void {
for (const [resourceId, mappedUuid] of Object.entries(stateSection)) {
if (mappedUuid === uuid && resourceId !== keepResourceId) {
for (const [resourceId, entry] of Object.entries(stateSection)) {
if (entry.uuid === uuid && resourceId !== keepResourceId) {
delete stateSection[resourceId];
}
}
Expand Down Expand Up @@ -368,8 +373,8 @@ function buildReverseMap(
const map = new Map<string, string>();
const stateSection = state[resourceType];

for (const [resourceId, uuid] of Object.entries(stateSection)) {
map.set(uuid, resourceId);
for (const [resourceId, entry] of Object.entries(stateSection)) {
map.set(entry.uuid, resourceId);
}

return map;
Expand Down Expand Up @@ -653,7 +658,7 @@ export async function pullResourceType(

const reverseMap = buildReverseMap(state, resourceType);
const credReverse = credentialReverseMap(state);
const newStateSection: Record<string, string> = resourceIds?.length
const newStateSection: Record<string, ResourceState> = resourceIds?.length
? { ...state[resourceType] }
: {};
const existingResourceIds = bootstrap
Expand Down Expand Up @@ -728,7 +733,7 @@ export async function pullResourceType(
changedFiles.has(yamlPath)
) {
console.log(` ✏️ ${resourceId} (locally modified, preserving)`);
newStateSection[resourceId] = resource.id;
upsertState(newStateSection, resourceId, { uuid: resource.id });
skipped++;
continue;
}
Expand Down Expand Up @@ -761,7 +766,7 @@ export async function pullResourceType(
const stateMtime = statSync(stateFilePath).mtimeMs;
if (localMtime > stateMtime) {
console.log(` ✏️ ${resourceId} (locally modified, preserving)`);
newStateSection[resourceId] = resource.id;
upsertState(newStateSection, resourceId, { uuid: resource.id });
skipped++;
continue;
}
Expand All @@ -783,7 +788,7 @@ export async function pullResourceType(
console.log(
` 🗑️ ${resourceId} (deleted locally, intent in state — add to .vapi-ignore to stop tracking)`,
);
newStateSection[resourceId] = resource.id;
upsertState(newStateSection, resourceId, { uuid: resource.id });
skipped++;
continue;
}
Expand Down Expand Up @@ -825,8 +830,15 @@ export async function pullResourceType(
if (isNew) created++;
else updated++;

// Update state
newStateSection[resourceId] = resource.id;
// Update state with new content hash + timestamp (Stack F).
// Hashing the resolved-with-credentials payload (the form we will save
// to disk) keeps `lastPulledHash` aligned with the source-of-truth diff
// basis used by drift detection in Stack G.
upsertState(newStateSection, resourceId, {
uuid: resource.id,
lastPulledHash: hashPayload(withCredNames),
lastPulledAt: new Date().toISOString(),
});
}

// Update state with new mappings
Expand Down
Loading