Skip to content

Commit e4678c0

Browse files
authored
Merge pull request #38 from VapiAI/feat/pull-drift-direction-classifier
feat(drift): three-way drift-direction classifier + content-drift audit + canonicalization fixes
2 parents fd8c23e + 1d7dfe5 commit e4678c0

10 files changed

Lines changed: 1189 additions & 86 deletions

File tree

AGENTS.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,34 @@ npm run apply -- <org> <path-to-file> # single-file apply (same safety, scoped d
9292

9393
Runs the engine's local validators against every YAML/MD file in the org without any network call. Catches shape errors (missing required fields, wrong types, stale tool references) before they burn a deploy. **Run before every `apply`.**
9494

95+
### `npm run pull -- <org>` — refresh from dashboard (default: plain pull)
96+
97+
**Default to plain pull, NOT --force.** Plain `npm run pull -- <org>` shows:
98+
- Per-resource direction labels (dashboard-ahead / local-ahead / both-diverged / clean)
99+
- End-of-pull summary with counts per direction
100+
- A hard gate (`--resolve` flag) on 3-way conflicts before they silently lose data
101+
102+
`--force` skips all of this and just overwrites local with dashboard. Use it ONLY when you literally need to nuke local and re-materialize dashboard truth (rare). Plain pull is the DEFAULT for both humans and agents; `--force` is the escape hatch.
103+
104+
**Pull-output icon legend.** Distinct semantics in a single pulled-resource line:
105+
106+
| Icon | Meaning |
107+
|------|---------|
108+
| `📝` | Engine wrote/updated a file on disk (clean / no-baseline path) |
109+
| `` | Engine created a NEW file on disk (first-time pull of this resource) |
110+
| `✏️` | Locally modified file detected by git, preserved as-is |
111+
| `⬆️` | `local-ahead` — local has unpushed edits, needs to flow UP to dashboard (preserved) |
112+
| `⬇️` | `--resolve=theirs` — overwrote local with dashboard (flowed DOWN) |
113+
| `🔒` | Platform-default resource (read-only, immutable) |
114+
| `🚫` | Matched `.vapi-ignore` (not tracked locally) |
115+
| `🗑️` | Locally deleted (deletion intent recorded in state) |
116+
117+
Mental model: `⬆️` flows UP (push), `⬇️` flows DOWN (pull-overwrite), `📝` is the engine doing routine file I/O.
118+
119+
**First-adoption noise on customer repos.** The first `npm run audit -- <org>` after upgrading to a version with the content-drift rule will surface `[content-drift] [no-baseline]` info findings for every resource whose state row predates `lastPulledHash` tracking. These are info-severity (do NOT block CI), but the noise is real. Two ways to clear:
120+
- `npm run pull -- <org> --bootstrap` — refreshes state with the current platform-hash baseline, no resource materialization. One-shot fix for the whole fleet.
121+
- Or just let them sit — each plain `npm run pull` after this writes `lastPulledHash` for whichever resources it touches, so the no-baseline rows naturally drain over the next few sync cycles.
122+
95123
### `npm run push -- <org>` — raw push, no pre-pull
96124

97125
Skips the merge pass. Only use when (a) you literally just ran `pull` and (b) you're certain no one has touched the dashboard since. In a multi-developer environment or when dashboard editors are in play, default to `apply` instead. Stale local state can clobber recent dashboard edits or PATCH against UUIDs that no longer exist.

src/audit-cmd.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,14 @@ import { VALID_RESOURCE_TYPES } from "./types.ts";
2323

2424
// Single source of truth for the exit-code contract. Exported so tests can pin
2525
// behavior without duplicating the predicate.
26+
//
27+
// `info`-severity findings (e.g. `no-baseline` from content-drift on fresh
28+
// clones) are surfaced for operator visibility but do NOT block CI. Without
29+
// this carve-out, every customer's first audit after this feature lands would
30+
// fail their build pipeline. `warn` and `error` still exit 1.
2631
export function exitCodeForFindings(findings: AuditFinding[]): 0 | 1 {
27-
return findings.length === 0 ? 0 : 1;
32+
const actionable = findings.filter((f) => f.severity !== "info");
33+
return actionable.length === 0 ? 0 : 1;
2834
}
2935

3036
function groupFindings(

src/audit.ts

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
// 5. sibling-base-slug multiple slugs sharing the same base-slug
1111
// 6. dashboard-orphan dashboard UUID not in state (.vapi-ignore suppresses)
1212
// 7. inline-tools assistant with non-empty model.tools (use toolIds)
13+
// 8. content-drift local vs dashboard vs lastPulledHash direction
1314
//
1415
// Designed for dependency injection: state loader, local file lister, remote
1516
// fetcher, and per-assistant tool reader are all swappable so tests stay
@@ -21,15 +22,18 @@
2122
import { readFile } from "fs/promises";
2223
import { join } from "path";
2324
import { matchesIgnore, RESOURCES_DIR } from "./config.ts";
25+
import { credentialReverseMap } from "./credentials.ts";
26+
import { classifyDrift } from "./drift.ts";
2427
import { findOrphanResourceIds } from "./new-file-gate.ts";
2528
import {
29+
canonicalizeForHash,
2630
fetchAllResources,
2731
listExistingResourceIds,
2832
type VapiResource,
2933
} from "./pull.ts";
30-
import { FOLDER_MAP } from "./resources.ts";
34+
import { FOLDER_MAP, hashLocalResource } from "./resources.ts";
3135
import { extractBaseSlug, slugify } from "./slug-utils.ts";
32-
import { loadState } from "./state.ts";
36+
import { hashPayload, loadState } from "./state.ts";
3337
import type { ResourceType, StateFile } from "./types.ts";
3438
import { VALID_RESOURCE_TYPES } from "./types.ts";
3539

@@ -45,9 +49,10 @@ export type AuditRule =
4549
| "sibling-base-slug"
4650
| "dashboard-orphan"
4751
| "inline-tools"
52+
| "content-drift"
4853
| "fetch-failed";
4954

50-
export type AuditSeverity = "warn" | "error";
55+
export type AuditSeverity = "info" | "warn" | "error";
5156

5257
export interface AuditFinding {
5358
severity: AuditSeverity;
@@ -345,6 +350,89 @@ function checkDashboardOrphans(
345350
return findings;
346351
}
347352

353+
function contentDriftSuggestedAction(direction: string): string {
354+
if (direction === "dashboard-ahead") {
355+
return "run npm run pull to refresh, or push --overwrite to take ownership";
356+
}
357+
if (direction === "local-ahead") {
358+
return "run npm run push to sync up";
359+
}
360+
if (direction === "both-diverged") {
361+
return "run npm run pull --resolve=ours|theirs|fail to resolve";
362+
}
363+
if (direction === "no-baseline") {
364+
return "run npm run pull --bootstrap to seed lastPulledHash baseline";
365+
}
366+
return "no action needed";
367+
}
368+
369+
function checkContentDrift(
370+
type: ResourceType,
371+
state: StateFile,
372+
remote: VapiResource[],
373+
localIds: string[],
374+
): AuditFinding[] {
375+
const remoteByUuid = new Map(remote.map((r) => [r.id, r]));
376+
const credReverse = credentialReverseMap(state);
377+
const findings: AuditFinding[] = [];
378+
379+
for (const resourceId of localIds) {
380+
const entry = state[type][resourceId];
381+
if (!entry) continue;
382+
383+
const localHash = hashLocalResource(type, resourceId);
384+
if (!localHash) continue;
385+
386+
const remoteResource = remoteByUuid.get(entry.uuid);
387+
if (!remoteResource) continue;
388+
389+
// canonicalizeForHash centralizes the 3-step pipeline + _platformDefault
390+
// mutation so the hash agrees with pull-write's lastPulledHash basis.
391+
const platformHash = hashPayload(
392+
canonicalizeForHash(remoteResource, state, credReverse),
393+
);
394+
const direction = classifyDrift({
395+
localHash,
396+
lastPulledHash: entry.lastPulledHash,
397+
platformHash,
398+
});
399+
if (direction === "clean") continue;
400+
401+
// Severity mapping:
402+
// - both-diverged → error (CI-blocking; needs --resolve to disambiguate)
403+
// - local-ahead → warn (un-pushed edits; operator should push)
404+
// - dashboard-ahead → warn (UI edits not pulled; operator should pull)
405+
// - no-baseline → info (expected on fresh clones / pre-bootstrap;
406+
// surfaces what would otherwise be silently
407+
// skipped, but does NOT block CI)
408+
const severity =
409+
direction === "both-diverged"
410+
? "error"
411+
: direction === "no-baseline"
412+
? "info"
413+
: "warn";
414+
const detail =
415+
direction === "local-ahead"
416+
? "local has unpushed edits"
417+
: direction === "dashboard-ahead"
418+
? "local diverges from dashboard since last pull"
419+
: direction === "both-diverged"
420+
? "3-way conflict between local, dashboard, and last-pulled baseline"
421+
: "no lastPulledHash baseline — content-drift coverage uninitialized";
422+
423+
findings.push({
424+
severity,
425+
type,
426+
rule: "content-drift",
427+
resourceIds: [resourceId],
428+
message: `${detail} [${direction}]`,
429+
suggestedAction: contentDriftSuggestedAction(direction),
430+
});
431+
}
432+
433+
return findings;
434+
}
435+
348436
async function checkInlineTools(
349437
state: StateFile,
350438
readAssistantTools: (resourceId: string) => unknown[] | Promise<unknown[]>,
@@ -434,6 +522,7 @@ export async function runAudit(
434522
const remoteUuids = new Set(remote.map((r) => r.id));
435523
findings.push(...checkStateGhosts(type, state, remoteUuids));
436524
findings.push(...checkDashboardOrphans(type, state, remote));
525+
findings.push(...checkContentDrift(type, state, remote, localIds));
437526
}
438527

439528
findings.push(...checkStateUuidCollisions(type, state));
@@ -457,7 +546,8 @@ export async function runAudit(
457546
// ─────────────────────────────────────────────────────────────────────────────
458547

459548
export function formatFinding(f: AuditFinding): string {
460-
const icon = f.severity === "error" ? "❌" : "⚠️ ";
549+
const icon =
550+
f.severity === "error" ? "❌" : f.severity === "info" ? "ℹ️ " : "⚠️ ";
461551
const uuidSuffix = f.uuid ? ` [uuid=${f.uuid}]` : "";
462552
const lines = [
463553
` ${icon} [${f.rule}] ${f.type}/${f.resourceIds.join(", ")}${uuidSuffix}`,
@@ -473,5 +563,8 @@ export function summarizeFindings(findings: AuditFinding[]): string {
473563
if (findings.length === 0) return "✅ No audit findings.";
474564
const errors = findings.filter((f) => f.severity === "error").length;
475565
const warns = findings.filter((f) => f.severity === "warn").length;
476-
return `📋 Audit: ${findings.length} finding(s) — ${errors} error(s), ${warns} warning(s)`;
566+
const infos = findings.filter((f) => f.severity === "info").length;
567+
const parts = [`${errors} error(s)`, `${warns} warning(s)`];
568+
if (infos > 0) parts.push(`${infos} info finding(s)`);
569+
return `📋 Audit: ${findings.length} finding(s) — ${parts.join(", ")}`;
477570
}

src/drift.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,45 @@ import { VAPI_BASE_URL, VAPI_TOKEN } from "./config.ts";
2222
import { hashPayload } from "./state-serialize.ts";
2323
import type { ResourceState } from "./types.ts";
2424

25+
export type DriftDirection =
26+
| "clean"
27+
| "dashboard-ahead"
28+
| "local-ahead"
29+
| "both-diverged"
30+
| "no-baseline";
31+
32+
export interface ClassifyDriftInput {
33+
localHash: string;
34+
lastPulledHash?: string;
35+
platformHash: string;
36+
}
37+
38+
export function classifyDrift(input: ClassifyDriftInput): DriftDirection {
39+
const { localHash, lastPulledHash, platformHash } = input;
40+
if (!lastPulledHash) return "no-baseline";
41+
const localMatches = localHash === lastPulledHash;
42+
const platformMatches = platformHash === lastPulledHash;
43+
if (localMatches && platformMatches) return "clean";
44+
if (localMatches && !platformMatches) return "dashboard-ahead";
45+
if (!localMatches && platformMatches) return "local-ahead";
46+
return "both-diverged";
47+
}
48+
49+
export function formatDriftLabel(direction: DriftDirection): string {
50+
switch (direction) {
51+
case "dashboard-ahead":
52+
return "[dashboard-ahead — sync down via plain pull (preserves local) or push --overwrite to take ownership]";
53+
case "local-ahead":
54+
return "[local-ahead — run npm run push to propagate local edits up]";
55+
case "both-diverged":
56+
return "[both-diverged — 3-way conflict, pass --resolve=ours|theirs|fail]";
57+
case "no-baseline":
58+
return "[direction unknown — no lastPulledHash baseline; pull --bootstrap first]";
59+
case "clean":
60+
return "[no drift]";
61+
}
62+
}
63+
2564
export interface DriftCheckResult {
2665
ok: boolean;
2766
reason: "no-baseline" | "match" | "drift-overwritten" | "drift-blocked";
@@ -73,14 +112,20 @@ function stripServerFields(payload: unknown): unknown {
73112
return out;
74113
}
75114

115+
export function hashPlatformResource(resource: unknown): string {
116+
return hashPayload(stripServerFields(resource));
117+
}
118+
76119
export async function checkDriftForUpdate(options: {
77120
endpoint: string; // e.g. "/assistant/<uuid>"
78121
resourceLabel: string; // for log lines
79122
resourceId: string; // local resource id
80123
state: ResourceState;
81124
overwrite: boolean;
125+
localHash?: string;
82126
}): Promise<DriftCheckResult> {
83-
const { endpoint, resourceLabel, resourceId, state, overwrite } = options;
127+
const { endpoint, resourceLabel, resourceId, state, overwrite, localHash } =
128+
options;
84129

85130
if (!state.lastPulledHash) {
86131
return {
@@ -104,14 +149,22 @@ export async function checkDriftForUpdate(options: {
104149
return { ok: true, reason: "match", platformHash };
105150
}
106151

152+
const effectiveLocalHash = localHash ?? state.lastPulledHash ?? platformHash;
153+
const direction = classifyDrift({
154+
localHash: effectiveLocalHash,
155+
lastPulledHash: state.lastPulledHash,
156+
platformHash,
157+
});
158+
const directionTag = `[${direction}]`;
159+
107160
if (overwrite) {
108161
return {
109162
ok: true,
110163
reason: "drift-overwritten",
111164
platformHash,
112165
message:
113-
` ⚠️ drift on ${resourceLabel} ${resourceId}: platform changed since last pull, ` +
114-
`overwriting (--overwrite).`,
166+
` ⚠️ drift on ${resourceLabel} ${resourceId} ${directionTag}: platform changed since last pull, ` +
167+
`overwriting (--overwrite). ${formatDriftLabel(direction)}`,
115168
};
116169
}
117170

@@ -120,9 +173,10 @@ export async function checkDriftForUpdate(options: {
120173
reason: "drift-blocked",
121174
platformHash,
122175
message:
123-
` ❌ drift detected on ${resourceLabel} ${resourceId}: ` +
176+
` ❌ drift detected on ${resourceLabel} ${resourceId} ${directionTag}: ` +
124177
`platform hash (${platformHash.slice(0, 8)}...) differs from last-pulled ` +
125178
`(${state.lastPulledHash.slice(0, 8)}...). ` +
179+
`${formatDriftLabel(direction)} ` +
126180
`Re-run pull, resolve locally, or push with --overwrite to take ownership.`,
127181
};
128182
}

src/pull-cmd.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
// Entry point for `npm run pull`. Detects whether an org slug was provided:
22
// - With slug: forwards to pull.ts (existing non-interactive behavior)
33
// - Without slug: enters interactive mode (org selection + resource picker)
4+
//
5+
// Pull flags (--force, --bootstrap, --resolve=ours|theirs|fail) are parsed
6+
// inside runPull from process.argv, same as --force.
47

58
const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
69
const arg = process.argv[2];

0 commit comments

Comments
 (0)