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
21 changes: 17 additions & 4 deletions improvements.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ you which stack PR closes the row.**
| 7 | Voice edits drop pronunciation-dictionary attachments | Silent regression on Cartesia + 11labs voice edits | #4 | RESOLVED 2026-04-30 (Stack G) |
| 8 | Dashboard prompt edits can in-place duplicate the prompt | Two stacked prompt versions = stitched output | None | Partial — Stack D heuristic |
| 9 | Provider-specific voice schema mismatch (push 400) | `voice.speed` vs `voice.generationConfig.speed` | None | RESOLVED 2026-04-30 (Stack D + A) |
| 10 | Targeted assistant push mints duplicate tools | Re-pushing assistant duplicates `end-call-*` tools | #4 | Partial |
| 10 | Targeted assistant push mints duplicate tools | Re-pushing assistant duplicates `end-call-*` tools | #4 | RESOLVED 2026-05-06 (#23) |
| 11 | Bidirectional SO ↔ assistant lockstep has no validation | One-sided edits silently inconsistent | None | RESOLVED 2026-04-30 (Stack D) |
| 12 | State file accumulates UUIDs without source files | Silent gitops drift | None | Partial |
| 13 | `.agent/` and `.claude/handoffs/` not gitignored | `git add -A` sweeps PII handoff scratch | None | RESOLVED 2026-04-30 (Stack A) |
Expand Down Expand Up @@ -533,6 +533,13 @@ cheat-sheet in `docs/learnings/voice-providers.md` (Stack A).

## 10. Targeted assistant pushes can auto-create duplicate tool dependencies

**[RESOLVED 2026-05-06] (#23)** — `ensureToolExists` /
`ensureStructuredOutputExists` now run a name-based dedup check (state-side
via `extractBaseSlug` + dashboard-side via lazy `/tool` list) between the
exact-key short-circuit and the create path. Adoption re-keys state to the
canonical UUID, drops stale duplicate state keys (orphan-deletion guard),
and routes through `applyTool` for the standard PATCH + drift-check flow.

**Discovered:** customer-fork log (Amazon3p #10, 2026-04-29)

### Problem
Expand Down Expand Up @@ -578,9 +585,15 @@ Before re-pushing an assistant with local tool dependencies, inspect

### Status

**Partial.** `ensureToolExists()` blocks the most common path; the
state-renaming case remains. **Stack C dry-run** surfaces auto-apply
intent before mutation.
**Resolved 2026-05-06 (#23).** Name-based dedup check (state-side +
dashboard-side) added between the exact-key short-circuit and the create
path. Adoption re-keys state to the canonical UUID, removes stale duplicate
state keys (touched-marked so `mergeScoped` flushes the deletion), and
routes through `applyTool` so a PATCH + drift check fires with the local
payload (no fake `lastPushedHash` recorded). Dashboard-side dedup honors
dry-run by skipping the API call. Prior partial mitigation
(`ensureToolExists` exact-key check) remains as the fast path; the new
dedup is the second layer for the bootstrap-renamed case.

---

Expand Down
137 changes: 137 additions & 0 deletions src/dep-dedup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Gap #10 — dependency deduplication helpers.
//
// On a targeted assistant push, `ensureToolExists` / `ensureStructuredOutputExists`
// previously skipped auto-create only when (a) the dep id was UUID-shaped,
// (b) `state.tools[depId]` was an exact key match, or (c) we'd already
// auto-applied this id in the current run. Bootstrap pull (pull.ts:269-273)
// stores resources under `<slug>-<uuid8>` keys (e.g. `end-call-67aea057`),
// so a local file referencing `b2b-invoice-end-call` would miss the exact-key
// check and POST a duplicate dashboard tool. Repeated targeted pushes
// accumulated orphans on the dashboard.
//
// This module's helpers detect those collisions BEFORE create:
// - `findExistingResourceByName` — match local payload's canonical name
// against existing state entries (renamed/state-only keys) and the live
// dashboard list. Returns the UUID to adopt, plus an `ambiguous` flag
// when multiple distinct UUIDs share the same name (real on-dashboard
// duplicates from prior bug runs — the caller should warn and surface
// the loser UUIDs so a follow-up `npm run cleanup` can prune them).
//
// NOTE on duplication: `slugify` and `extractBaseSlug` here mirror the
// definitions in `src/pull.ts` (lines ~253-278). pull.ts imports config.ts,
// which calls `parseEnvironment()` at module load and `process.exit(1)`s
// without a CLI env arg — making it impossible to import in a unit test.
// This module imports ONLY from `./types.ts` so it stays testable in
// isolation. Five lines duplicated is the right tradeoff; do not "DRY"
// these back into pull.ts.

import type { ResourceState } from "./types.ts";

export interface RemoteResource {
id: string;
name?: string;
function?: { name?: string };
}

export interface DedupMatch {
uuid: string;
source: "state" | "dashboard" | "both";
ambiguous: boolean;
// Other distinct UUIDs we saw under the same canonical name. Empty when
// `ambiguous` is false. Caller should surface these in a warning so the
// user can run `npm run cleanup` to prune the duplicates.
duplicateUuids: string[];
}

export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-+/g, "-");
}

export function extractBaseSlug(resourceId: string): string {
const match = resourceId.match(/^(.*)-([a-f0-9]{8})$/i);
return match?.[1] ?? resourceId;
}

// Pulls the canonical name from a tool / SO / assistant payload.
// For tools: `function.name` is the canonical name (per pull.ts:261-267).
// For SOs / assistants / etc.: top-level `name`. Top-level wins when both
// are present.
export function extractResourceName(
payload: Record<string, unknown>,
): string | undefined {
if (typeof payload.name === "string" && payload.name) return payload.name;
const fn = payload.function as Record<string, unknown> | undefined;
if (fn && typeof fn.name === "string" && fn.name) return fn.name;
return undefined;
}

// Find an existing resource (in state or dashboard) whose canonical name
// matches the local payload's canonical name. Used by ensureToolExists /
// ensureStructuredOutputExists to avoid creating a duplicate when bootstrap
// pull has already stored the same dashboard resource under a different
// state key.
//
// `localResourceId` is the key the engine WANTS to use in state. If a state
// entry already exists under that exact key, the caller short-circuits BEFORE
// calling this — so we exclude it here as a safety belt.
//
// Tiebreaker for >1 distinct UUID matching the same slugified name (i.e.
// real on-dashboard duplicates from prior bug runs): pick lexically smallest
// UUID for stable, deterministic adoption. Set `ambiguous=true` and surface
// the loser UUIDs in `duplicateUuids` so the caller can warn.
export function findExistingResourceByName(args: {
localResourceId: string;
localPayload: Record<string, unknown>;
stateSection: Record<string, ResourceState>;
remoteList?: RemoteResource[];
}): DedupMatch | undefined {
const localName = extractResourceName(args.localPayload);
if (!localName) return undefined;
const localSlug = slugify(localName);

// uuid -> set of source labels (state:<key>, dashboard:<id>)
const matches = new Map<string, Set<string>>();

for (const [stateKey, entry] of Object.entries(args.stateSection)) {
if (stateKey === args.localResourceId) continue;
if (extractBaseSlug(stateKey) === localSlug) {
const set = matches.get(entry.uuid) ?? new Set<string>();
set.add(`state:${stateKey}`);
matches.set(entry.uuid, set);
}
}

for (const remote of args.remoteList ?? []) {
const remoteName =
(typeof remote.name === "string" && remote.name) || remote.function?.name;
if (!remoteName) continue;
if (slugify(remoteName) === localSlug) {
const set = matches.get(remote.id) ?? new Set<string>();
set.add(`dashboard:${remote.id}`);
matches.set(remote.id, set);
}
}

if (matches.size === 0) return undefined;

const sorted = [...matches.keys()].sort();
const winner = sorted[0]!;
const winnerSources = matches.get(winner)!;
const hasState = [...winnerSources].some((s) => s.startsWith("state:"));
const hasDashboard = [...winnerSources].some((s) =>
s.startsWith("dashboard:"),
);
const source: DedupMatch["source"] =
hasState && hasDashboard ? "both" : hasState ? "state" : "dashboard";

return {
uuid: winner,
source,
ambiguous: matches.size > 1,
duplicateUuids: sorted.slice(1),
};
}
Loading