diff --git a/docs/learnings/sync-behavior.md b/docs/learnings/sync-behavior.md index bbb3eed..b7532ad 100644 --- a/docs/learnings/sync-behavior.md +++ b/docs/learnings/sync-behavior.md @@ -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 [+dirty] by ` +- `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//` 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 diff --git a/src/push.ts b/src/push.ts index 3270e9e..2397197 100644 --- a/src/push.ts +++ b/src/push.ts @@ -13,6 +13,7 @@ import { loadIgnorePatterns, OVERWRITE_DRIFT, removeExcludedKeys, + RESOURCES_DIR, STATE_FILE_PATH, STRICT_VALIDATION, VAPI_BASE_URL, @@ -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. @@ -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 { + 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; @@ -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; } @@ -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. @@ -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) { @@ -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; } diff --git a/src/types.ts b/src/types.ts index d285a43..8e8e803 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; +} diff --git a/src/version-metadata.ts b/src/version-metadata.ts new file mode 100644 index 0000000..b231745 --- /dev/null +++ b/src/version-metadata.ts @@ -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 { + 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), + }; +} diff --git a/tests/version-metadata.test.ts b/tests/version-metadata.test.ts new file mode 100644 index 0000000..815baaf --- /dev/null +++ b/tests/version-metadata.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + changedFieldPaths, + versionActorResolve, + versionMetadataBuild, +} from "../src/version-metadata.ts"; + +const BASE = { + id: "a1", + orgId: "o1", + name: "Support agent", + updatedAt: "2026-10-01T00:00:00.000Z", + latestVersion: "v7", + model: { + provider: "openai", + model: "gpt-4.1", + messages: [{ role: "system", content: "old prompt" }], + }, + voice: { provider: "11labs", voiceId: "abc" }, +}; + +test("changedFieldPaths names the nested field a prompt edit touched", () => { + const after = { + ...BASE, + model: { + ...BASE.model, + messages: [{ role: "system", content: "new prompt" }], + }, + }; + assert.deepEqual(changedFieldPaths(BASE, after), ["model.messages"]); +}); + +test("changedFieldPaths ignores server-managed keys that move on every write", () => { + const after = { + ...BASE, + updatedAt: "2026-10-06T00:00:00.000Z", + latestVersion: "v8", + }; + assert.deepEqual(changedFieldPaths(BASE, after), []); +}); + +test("changedFieldPaths lists added and removed keys", () => { + const { voice: _voice, ...withoutVoice } = BASE; + const after = { ...withoutVoice, firstMessage: "Hi" }; + assert.deepEqual(changedFieldPaths(BASE, after), ["firstMessage", "voice"]); +}); + +test("changedFieldPaths stops descending at maxDepth", () => { + const after = { + ...BASE, + model: { ...BASE.model, provider: "anthropic", model: "claude-sonnet" }, + }; + assert.deepEqual(changedFieldPaths(BASE, after, 1), ["model"]); +}); + +test("versionMetadataBuild puts the actor and commit in the name and description", () => { + const metadata = versionMetadataBuild({ + actor: { name: "dev@example.com", commit: "abc1234", dirty: false }, + changedPaths: ["model.messages", "voice.voiceId"], + created: false, + }); + assert.deepEqual(metadata, { + versionName: "gitops abc1234 by dev@example.com", + versionDescription: + "Pushed by dev@example.com from commit abc1234 via vapi-gitops.\n" + + "Changed: model.messages, voice.voiceId", + }); +}); + +test("versionMetadataBuild marks a push with uncommitted edits as dirty", () => { + const metadata = versionMetadataBuild({ + actor: { name: "dev@example.com", commit: "abc1234", dirty: true }, + changedPaths: ["name"], + created: false, + }); + assert.equal(metadata.versionName, "gitops abc1234+dirty by dev@example.com"); +}); + +test("versionMetadataBuild fits a large change in the API limits with a +N more tail", () => { + const changedPaths = Array.from( + { length: 60 }, + (_, i) => `analysisPlan.field${i}`, + ); + const metadata = versionMetadataBuild({ + actor: { name: "x".repeat(120), commit: "abc1234", dirty: false }, + changedPaths, + created: false, + }); + assert.ok(metadata.versionName.length <= 80); + assert.ok(metadata.versionDescription.length <= 500); + assert.match(metadata.versionDescription, /\(\+\d+ more\)$/); +}); + +test("versionMetadataBuild says created for a first push", () => { + const metadata = versionMetadataBuild({ + actor: { name: "dev@example.com", commit: null, dirty: false }, + changedPaths: [], + created: true, + }); + assert.equal( + metadata.versionDescription, + "Pushed by dev@example.com from commit no-commit via vapi-gitops.\n" + + "Created by gitops.", + ); +}); + +test("versionActorResolve prefers an explicit actor over the CI and git identity", () => { + const actor = versionActorResolve("resources", { + VAPI_GITOPS_ACTOR: "release-bot", + GITHUB_ACTOR: "someone", + }); + assert.equal(actor.name, "release-bot"); +}); + +test("versionActorResolve uses the GitHub actor in CI", () => { + const actor = versionActorResolve("resources", { GITHUB_ACTOR: "someone" }); + assert.equal(actor.name, "github:someone"); +});