Skip to content
Draft
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
20 changes: 20 additions & 0 deletions docs/learnings/sync-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,26 @@ Do not delete the source state mapping or refresh it away before step 2. With
no source files and no tombstone, promotion refuses the empty-source mirror
instead of guessing that a full destination wipe was intended.

## Assistant version labels

On an org with assistant versioning, every assistant create or PATCH that
changes content publishes a new version (`v8`, `v9`, …). The platform records
no author on a version written with a private API key, so push labels it:

- `versionName`: `gitops <commit>[+dirty] by <actor>`
- `versionDescription`: the actor, the commit, and the dotted paths of the
fields that changed (`model.messages, voice.voiceId`), computed from the
dashboard payload before the PATCH and the PATCH response.

The actor is `VAPI_GITOPS_ACTOR` if set, else `github:$GITHUB_ACTOR` in CI,
else `git config user.email`. It is self-reported, so treat it as an audit
trail, not proof. `+dirty` means `resources/<org>/` had uncommitted edits, so
the commit alone does not reproduce the push.

Push skips the label when nothing new was published (identical content is
deduplicated) and in `--dry-run`. A failed label is a warning, never a failed
push: the content is already live.

---

## Flag cheat sheet
Expand Down
69 changes: 65 additions & 4 deletions src/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
loadIgnorePatterns,
OVERWRITE_DRIFT,
removeExcludedKeys,
RESOURCES_DIR,
STATE_FILE_PATH,
STRICT_VALIDATION,
VAPI_BASE_URL,
Expand All @@ -39,6 +40,11 @@ import {
validateNoIgnoredReferences,
validateResources,
} from "./validate.ts";
import {
changedFieldPaths,
versionActorResolve,
versionMetadataBuild,
} from "./version-metadata.ts";

// Map a resource label to its state-file key. Used for snapshotting —
// snapshot directories are keyed by the same names the state file uses.
Expand Down Expand Up @@ -165,6 +171,45 @@ async function writeBaselineFromResponse(
}
}

// Label the version an assistant push just published with who pushed it,
// from which commit, and which fields changed. Only assistants: they are the
// only resource whose versions accept metadata. Skips when the push published
// nothing new (the platform dedups identical content), and never blocks the
// push: the content is already live, the label is an audit trail.
async function writeAssistantVersionMetadata(options: {
uuid: string;
before: unknown;
after: unknown;
}): Promise<void> {
const { uuid, before, after } = options;
if (DRY_RUN) return;
const latestVersionRead = (value: unknown): string | null =>
value &&
typeof value === "object" &&
"latestVersion" in value &&
typeof value.latestVersion === "string"
? value.latestVersion
: null;
const published = latestVersionRead(after);
if (!published || published === latestVersionRead(before)) return;
const metadata = versionMetadataBuild({
actor: versionActorResolve(RESOURCES_DIR),
changedPaths: before === undefined ? [] : changedFieldPaths(before, after),
created: before === undefined,
});
try {
await vapiRequest("PATCH", `/assistant/${uuid}/versions/${published}`, {
...metadata,
});
console.log(` 🏷️ ${published}: ${metadata.versionName}`);
} catch (err) {
console.warn(
` ⚠️ failed to label ${published} of assistant ${uuid}: ` +
(err instanceof Error ? err.message : String(err)),
);
}
}

async function upsertResourceWithStateRecovery(options: {
resourceLabel: string;
resourceId: string;
Expand Down Expand Up @@ -197,6 +242,13 @@ async function upsertResourceWithStateRecovery(options: {
console.log(` ✨ Creating ${resourceLabel}: ${resourceId}`);
const result = await vapiRequest("POST", createEndpoint, createPayload);
await writeBaselineFromResponse(result.id, result, fullState);
if (resourceLabel === "assistant") {
await writeAssistantVersionMetadata({
uuid: result.id,
before: undefined,
after: result,
});
}
return result.id;
}

Expand All @@ -210,13 +262,14 @@ async function upsertResourceWithStateRecovery(options: {
// what would happen, and skipped if no baseline hash.
// When we successfully fetch the platform payload, snapshot it (and our
// outgoing payload) so `npm run rollback` has a target.
// Platform payload fetched by the drift check, reused for the rollback
// snapshot below so we don't fire a second GET at the same endpoint, and
// as the "before" side of the version-metadata field diff.
let platformPayloadForSnapshot: unknown;
if (!DRY_RUN) {
const stateEntry = stateSection[resourceId];
if (stateEntry) {
const driftResourceType = RESOURCE_LABEL_TO_TYPE[resourceLabel];
// Platform payload fetched by the drift check, reused for the rollback
// snapshot below so we don't fire a second GET at the same endpoint.
let platformPayloadForSnapshot: unknown;
// The drift check now owns the full hash computation (platform, local,
// and baseline all canonicalized via canonical.ts). Push just hands it
// the full state + resource type — no hash plumbing at the call site.
Expand Down Expand Up @@ -341,6 +394,13 @@ async function upsertResourceWithStateRecovery(options: {
// the freshest possible "last known platform state." Hash it as the new
// drift baseline so the next push of a further local edit is clean.
await writeBaselineFromResponse(existingUuid, result, fullState);
if (resourceLabel === "assistant" && platformPayloadForSnapshot) {
await writeAssistantVersionMetadata({
uuid: existingUuid,
before: platformPayloadForSnapshot,
after: result,
});
}
return existingUuid;
} catch (error) {
if (!(error instanceof VapiApiError) || error.statusCode !== 404) {
Expand Down Expand Up @@ -745,7 +805,8 @@ export function cleanDestinationAssistantIds(destinations: unknown): unknown {
function countAuthoredAssistantRefs(assistantIds: unknown): number {
if (!Array.isArray(assistantIds)) return 0;
return assistantIds.filter(
(ref) => typeof ref === "string" && (ref.split("##")[0]?.trim() ?? "") !== "",
(ref) =>
typeof ref === "string" && (ref.split("##")[0]?.trim() ?? "") !== "",
).length;
}

Expand Down
17 changes: 17 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,20 @@ export interface OrphanedResource {
resourceId: string;
uuid: string;
}

// Who pushed an assistant version, recorded in its version metadata.
export interface VersionActor {
// Who pushed: the CI actor when set, else the local git identity.
name: string;
// Short commit SHA of HEAD, or null outside a git checkout.
commit: string | null;
// True when the pushed files had uncommitted edits, so the commit alone
// does not reproduce what was pushed.
dirty: boolean;
}

// Body of PATCH /assistant/:id/versions/:version.
export interface VersionMetadata {
versionName: string;
versionDescription: string;
}
146 changes: 146 additions & 0 deletions src/version-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Labels the assistant version a push publishes with who pushed it, from
// which commit, and which fields changed. The platform records no author on
// versions written with a private API key (createdBy is null), so without
// this a version published by gitops cannot be traced back to a person.
//
// Config-free on purpose (like user-agent.ts): importing config.ts would
// parse argv and exit, which breaks importing this from tests.

import { execFileSync } from "node:child_process";
import type { VersionActor, VersionMetadata } from "./types.ts";

// Limits enforced by PATCH /assistant/:id/versions/:version. Longer values
// are rejected with a 400, so the builder truncates instead.
const VERSION_NAME_MAX = 80;
const VERSION_DESCRIPTION_MAX = 500;

// Server-managed keys that differ between any two reads of the same
// resource. Comparing them would list a change on every push.
const IGNORED_KEYS = new Set([
"id",
"orgId",
"createdAt",
"updatedAt",
"latestVersion",
"modelDeprecations",
]);

function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

function sameValue(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}

// Dotted paths of the fields that differ between two reads of a resource,
// descending into nested objects up to `maxDepth` levels (so a prompt edit
// reads `model.messages`, not just `model`). Arrays compare whole.
export function changedFieldPaths(
before: unknown,
after: unknown,
maxDepth = 2,
prefix = "",
): string[] {
if (!isPlainObject(before) || !isPlainObject(after)) {
return sameValue(before, after) ? [] : [prefix || "(root)"];
}

const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
const paths: string[] = [];
for (const key of keys) {
if (!prefix && IGNORED_KEYS.has(key)) continue;
const path = prefix ? `${prefix}.${key}` : key;
const a = before[key];
const b = after[key];
if (sameValue(a, b)) continue;
const depth = path.split(".").length;
if (depth < maxDepth && isPlainObject(a) && isPlainObject(b)) {
paths.push(...changedFieldPaths(a, b, maxDepth, path));
} else {
paths.push(path);
}
}
return paths.sort();
}

function gitRead(args: string[]): string | null {
try {
const out = execFileSync("git", args, {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return out || null;
} catch {
return null;
}
}

// Self-reported identity: git config and GITHUB_ACTOR are whatever the
// pusher's environment says. Good for an audit trail between colleagues,
// not proof against a pusher who sets them deliberately.
export function versionActorResolve(
resourcesDir: string,
env: NodeJS.ProcessEnv = process.env,
): VersionActor {
const name =
env.VAPI_GITOPS_ACTOR ||
(env.GITHUB_ACTOR ? `github:${env.GITHUB_ACTOR}` : null) ||
gitRead(["config", "user.email"]) ||
gitRead(["config", "user.name"]) ||
"unknown";
const commit = gitRead(["rev-parse", "--short", "HEAD"]);
const dirty =
commit !== null &&
gitRead(["status", "--porcelain", "--", resourcesDir]) !== null;
return { name, commit, dirty };
}

function truncate(value: string, max: number): string {
return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
}

// Lists as many changed paths as fit in the description limit, then
// "+N more" so a large change never pushes the actor line out.
function changedFieldsLine(paths: string[], budget: number): string {
if (paths.length === 0) return "Changed: (no field-level change detected)";
const head = "Changed: ";
let line = head;
for (let i = 0; i < paths.length; i++) {
const remaining = paths.length - i - 1;
const sep = i === 0 ? "" : ", ";
const tail = remaining > 0 ? ` (+${remaining} more)` : "";
const candidate = `${line}${sep}${paths[i]}`;
if (candidate.length + tail.length > budget) {
return `${line} (+${paths.length - i} more)`;
}
line = candidate;
}
return line;
}

export function versionMetadataBuild(args: {
actor: VersionActor;
changedPaths: string[];
created: boolean;
}): VersionMetadata {
const { actor, changedPaths, created } = args;
const commit = actor.commit
? `${actor.commit}${actor.dirty ? "+dirty" : ""}`
: "no-commit";
const versionName = truncate(
`gitops ${commit} by ${actor.name}`,
VERSION_NAME_MAX,
);
const header = `Pushed by ${actor.name} from commit ${commit} via vapi-gitops.`;
const body = created
? "Created by gitops."
: changedFieldsLine(
changedPaths,
VERSION_DESCRIPTION_MAX - header.length - 1,
);
return {
versionName,
versionDescription: truncate(`${header}\n${body}`, VERSION_DESCRIPTION_MAX),
};
}
Loading
Loading