diff --git a/.changeset/structured-plan-semantics.md b/.changeset/structured-plan-semantics.md new file mode 100644 index 0000000..d200240 --- /dev/null +++ b/.changeset/structured-plan-semantics.md @@ -0,0 +1,5 @@ +--- +"@openagentpack/sdk": patch +--- + +Classify Agent readiness from structured plan impact and changed paths instead of parsing display text. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26eb603..677eb17 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -121,6 +121,7 @@ jobs: registry-ready: needs: [preflight, publish] runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read steps: diff --git a/apps/server/openapi.json b/apps/server/openapi.json index 78f7eed..9502589 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -803,6 +803,16 @@ "type": "string", "enum": ["none", "local", "remote", "both"] }, + "readinessImpact": { + "type": "string", + "enum": ["none", "non_blocking", "blocking"] + }, + "changedPaths": { + "type": "array", + "items": { + "type": "string" + } + }, "before": { "type": "object", "additionalProperties": { diff --git a/apps/webui/src/lib/api/generated/schema.d.ts b/apps/webui/src/lib/api/generated/schema.d.ts index ba8799f..7286b36 100644 --- a/apps/webui/src/lib/api/generated/schema.d.ts +++ b/apps/webui/src/lib/api/generated/schema.d.ts @@ -298,6 +298,9 @@ export interface paths { reason: string; /** @enum {string} */ driftKind?: "none" | "local" | "remote" | "both"; + /** @enum {string} */ + readinessImpact?: "none" | "non_blocking" | "blocking"; + changedPaths?: string[]; before?: { [key: string]: unknown; }; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 255294e..c20fc58 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -192,6 +192,7 @@ export { ListSessionsRequestSchema, ListSessionsResponseSchema, PlannedActionSchema, + PlanReadinessImpactSchema, ResourceAddressSchema, ResourceTypeSchema, SendEventRequestSchema, @@ -232,6 +233,7 @@ export type { ListSessionsRequest, ListSessionsResponse, PlannedAction, + PlanReadinessImpact, ResourceAddress, ResourceType, SendEventRequest, diff --git a/packages/sdk/src/internal/core/agent-runtime.ts b/packages/sdk/src/internal/core/agent-runtime.ts index d311996..bb0c77e 100644 --- a/packages/sdk/src/internal/core/agent-runtime.ts +++ b/packages/sdk/src/internal/core/agent-runtime.ts @@ -474,9 +474,7 @@ function actionKey(action: PlannedAction): string { function isNonBlockingAgentDrift(action: PlannedAction): boolean { if (action.action === "no-op") return true; - if (action.action !== "update") return false; - const reason = action.reason.toLowerCase(); - return reason.includes("metadata") || reason.includes("description"); + return action.readinessImpact === "non_blocking"; } export function collectAgentAddresses(config: ProjectConfig, agentName: string, provider?: string): ResourceAddress[] { diff --git a/packages/sdk/src/internal/core/resource-runtime.ts b/packages/sdk/src/internal/core/resource-runtime.ts index 388b69c..3579e49 100644 --- a/packages/sdk/src/internal/core/resource-runtime.ts +++ b/packages/sdk/src/internal/core/resource-runtime.ts @@ -1,5 +1,7 @@ import { UserError } from "../errors.ts"; import { type ExecutionResult, executePlan } from "../executor/executor.ts"; +import { getResourceDeclaration } from "../planner/declaration.ts"; +import { buildReadinessBaseline } from "../planner/plan-semantics.ts"; import { buildPlan } from "../planner/planner.ts"; import { type RefreshResult, refreshState } from "../planner/refresh.ts"; import type { ExecutionPlan, PlannedAction } from "../types/plan.ts"; @@ -126,6 +128,7 @@ export async function importResource( version: options.resourceVersion, content_hash: contentHash, desired_hash: contentHash, + desired_readiness_baseline: buildReadinessBaseline(getResourceDeclaration(address, ctx.config)), }; ctx.state.setResource(resource); await ctx.state.save(); diff --git a/packages/sdk/src/internal/executor/executor.ts b/packages/sdk/src/internal/executor/executor.ts index 130734e..69e6e1c 100644 --- a/packages/sdk/src/internal/executor/executor.ts +++ b/packages/sdk/src/internal/executor/executor.ts @@ -1,7 +1,9 @@ import { dirname, resolve } from "node:path"; import { UserError } from "../errors.ts"; import { computeComparableDesiredHash } from "../planner/comparable.ts"; +import { getResourceDeclaration } from "../planner/declaration.ts"; import { computeResourceHash } from "../planner/hasher.ts"; +import { buildReadinessBaseline } from "../planner/plan-semantics.ts"; import { ApiError, ConflictError } from "../providers/base-client.ts"; import { readComparableIfSupported } from "../providers/drift-support.ts"; import type { RemoteResource } from "../providers/interface.ts"; @@ -454,8 +456,10 @@ async function executeAction(action: PlannedAction, provider: ResourceExecAdapte content_hash: hash, desired_hash: hash, desired_comparable_hash: remoteHash, + desired_readiness_baseline: buildReadinessBaseline(getResourceDeclaration(address, ctx.config)), remote_hash: remoteHash, remote_snapshot: remoteSnapshot, + drift_paths: [], drift_status: remoteHash ? "in_sync" : undefined, }); return adopted; diff --git a/packages/sdk/src/internal/planner/plan-semantics.ts b/packages/sdk/src/internal/planner/plan-semantics.ts new file mode 100644 index 0000000..1f85d6e --- /dev/null +++ b/packages/sdk/src/internal/planner/plan-semantics.ts @@ -0,0 +1,72 @@ +import type { ActionType, PlanReadinessImpact } from "../types/dto.ts"; +import type { ResourceReadinessBaseline } from "../types/state.ts"; +import { contentHash } from "../utils/hash.ts"; + +const NON_BLOCKING_ROOT_FIELDS = new Set(["description", "metadata"]); + +/** + * Return stable, leaf-oriented paths whose values differ between two JSON-like + * resource snapshots. Arrays are treated atomically because their ordering is + * part of the declared resource semantics. + */ +export function diffChangedPaths(before: unknown, after: unknown, prefix = ""): string[] { + if (Object.is(before, after)) return []; + if (Array.isArray(before) || Array.isArray(after)) { + return structurallyEqual(before, after) ? [] : [prefix || "$root"]; + } + if (isRecord(before) && isRecord(after)) { + const paths: string[] = []; + const keys = new Set([...Object.keys(before), ...Object.keys(after)]); + for (const key of [...keys].sort()) { + const path = prefix ? `${prefix}.${key}` : key; + paths.push(...diffChangedPaths(before[key], after[key], path)); + } + return paths; + } + return [prefix || "$root"]; +} + +/** + * Classify whether an already-provisioned Agent Harness can keep running while + * a planned action is pending. Unknown changes are deliberately blocking. + */ +export function classifyReadinessImpact( + action: ActionType, + changedPaths: readonly string[] | undefined, +): PlanReadinessImpact { + if (action === "no-op") return "none"; + if (action !== "update" || !changedPaths || changedPaths.length === 0) return "blocking"; + return changedPaths.every(isNonBlockingPath) ? "non_blocking" : "blocking"; +} + +/** Store only irreversible hashes in state; declarations may contain secrets. */ +export function buildReadinessBaseline(declaration: unknown): ResourceReadinessBaseline { + const record = isRecord(declaration) ? declaration : {}; + const { description: _description, metadata: _metadata, ...operational } = record; + return { + operational_hash: contentHash(operational), + description_hash: contentHash(record.description ?? null), + metadata_hash: contentHash(record.metadata ?? null), + }; +} + +export function diffReadinessBaseline(before: ResourceReadinessBaseline, after: ResourceReadinessBaseline): string[] { + const paths: string[] = []; + if (before.operational_hash !== after.operational_hash) paths.push("$operational"); + if (before.description_hash !== after.description_hash) paths.push("description"); + if (before.metadata_hash !== after.metadata_hash) paths.push("metadata"); + return paths; +} + +function isNonBlockingPath(path: string): boolean { + const root = path.split(".", 1)[0]; + return root !== undefined && NON_BLOCKING_ROOT_FIELDS.has(root); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function structurallyEqual(left: unknown, right: unknown): boolean { + return contentHash(left) === contentHash(right); +} diff --git a/packages/sdk/src/internal/planner/planner.ts b/packages/sdk/src/internal/planner/planner.ts index 871a597..4dbefc3 100644 --- a/packages/sdk/src/internal/planner/planner.ts +++ b/packages/sdk/src/internal/planner/planner.ts @@ -9,7 +9,9 @@ import type { ProjectConfig } from "../types/config.ts"; import type { ExecutionPlan, PlannedAction } from "../types/plan.ts"; import type { ResourceAddress, StateFile } from "../types/state.ts"; import { addressKey } from "../types/state.ts"; +import { getResourceDeclaration } from "./declaration.ts"; import { computeResourceHash } from "./hasher.ts"; +import { buildReadinessBaseline, classifyReadinessImpact, diffReadinessBaseline } from "./plan-semantics.ts"; export interface PlanOptions { providers?: string[]; @@ -48,6 +50,7 @@ export async function buildPlan( action: "create", address, driftKind: "none", + readinessImpact: "blocking", reason: "Resource does not exist in state", after: { content_hash: desiredHash }, dependencies: deps, @@ -56,10 +59,13 @@ export async function buildPlan( (existing.desired_hash ?? existing.content_hash) !== desiredHash && existing.drift_status === "drifted" ) { + const changedPaths = collectChangedPaths(address, config, existing, true); actions.push({ action: "update", address, driftKind: "both", + readinessImpact: classifyReadinessImpact("update", changedPaths), + changedPaths, reason: "Local config changed and remote drift detected", before: { content_hash: existing.desired_hash ?? existing.content_hash, @@ -70,20 +76,26 @@ export async function buildPlan( dependencies: deps, }); } else if ((existing.desired_hash ?? existing.content_hash) !== desiredHash) { + const changedPaths = collectChangedPaths(address, config, existing, false); actions.push({ action: "update", address, driftKind: "local", + readinessImpact: classifyReadinessImpact("update", changedPaths), + changedPaths, reason: "Local config changed", before: { content_hash: existing.desired_hash ?? existing.content_hash }, after: { content_hash: desiredHash }, dependencies: deps, }); } else if (existing.drift_status === "drifted") { + const changedPaths = existing.drift_paths; actions.push({ action: "update", address, driftKind: "remote", + readinessImpact: classifyReadinessImpact("update", changedPaths), + changedPaths, reason: "Remote drift detected", before: { content_hash: existing.desired_hash ?? existing.content_hash, @@ -98,6 +110,7 @@ export async function buildPlan( action: "no-op", address, driftKind: "none", + readinessImpact: "none", reason: existing.drift_status === "unchecked" ? "No changes detected (remote content drift unchecked)" @@ -116,6 +129,7 @@ export async function buildPlan( action: "delete", address: res.address, driftKind: "none", + readinessImpact: "blocking", reason: "Resource removed from configuration", before: { content_hash: res.desired_hash ?? res.content_hash }, dependencies: [], @@ -125,6 +139,21 @@ export async function buildPlan( return { actions, diagnostics: diagnostics.getAll() }; } +function collectChangedPaths( + address: ResourceAddress, + config: ProjectConfig, + existing: StateFile["resources"][number], + includeRemote: boolean, +): string[] | undefined { + const current = buildReadinessBaseline(getResourceDeclaration(address, config)); + const localPaths = existing.desired_readiness_baseline + ? diffReadinessBaseline(existing.desired_readiness_baseline, current) + : undefined; + if (!includeRemote) return localPaths; + if (!localPaths && !existing.drift_paths) return undefined; + return [...new Set([...(localPaths ?? []), ...(existing.drift_paths ?? [])])].sort(); +} + function getDependencies(address: ResourceAddress, graph: ReturnType): ResourceAddress[] { const key = addressKey(address); const depKeys = graph.edges.get(key) ?? new Set(); diff --git a/packages/sdk/src/internal/planner/refresh.ts b/packages/sdk/src/internal/planner/refresh.ts index dbe6705..31a44f7 100644 --- a/packages/sdk/src/internal/planner/refresh.ts +++ b/packages/sdk/src/internal/planner/refresh.ts @@ -6,6 +6,7 @@ import { emitRuntimeFeedback, type RuntimeFeedbackSink } from "../types/runtime- import type { ResourceState } from "../types/state.ts"; import { contentHash } from "../utils/hash.ts"; import { getResourceDeclaration } from "./declaration.ts"; +import { diffChangedPaths } from "./plan-semantics.ts"; export interface RefreshResult { removed: ResourceState[]; @@ -75,6 +76,10 @@ export async function refreshState( const desiredComparableHash = desiredComparable === null ? undefined : contentHash(desiredComparable); const baselineHash = res.desired_comparable_hash ?? desiredComparableHash; const driftStatus = baselineHash && remoteHash !== baselineHash ? "drifted" : "in_sync"; + const driftPaths = + driftStatus === "drifted" && desiredComparable !== null + ? diffChangedPaths(desiredComparable, remote.comparable) + : []; state.setResource({ ...res, @@ -84,6 +89,7 @@ export async function refreshState( desired_comparable_hash: res.desired_comparable_hash ?? desiredComparableHash, remote_hash: remoteHash, remote_snapshot: remote.snapshot ?? remote.comparable, + drift_paths: driftPaths, drift_status: driftStatus, }); dirty = true; diff --git a/packages/sdk/src/internal/state/state-manager.ts b/packages/sdk/src/internal/state/state-manager.ts index 4fef18b..793c89f 100644 --- a/packages/sdk/src/internal/state/state-manager.ts +++ b/packages/sdk/src/internal/state/state-manager.ts @@ -43,8 +43,10 @@ export class StateManager implements IStateManager { content_hash: ((r.content_hash ?? r.desired_hash) as string) ?? "", desired_hash: ((r.desired_hash ?? r.content_hash) as string) ?? "", desired_comparable_hash: r.desired_comparable_hash as string | undefined, + desired_readiness_baseline: r.desired_readiness_baseline as ResourceState["desired_readiness_baseline"], remote_hash: r.remote_hash as string | undefined, remote_snapshot: r.remote_snapshot, + drift_paths: r.drift_paths as string[] | undefined, drift_status: r.drift_status as ResourceState["drift_status"], })); return new StateManager({ resources }, path); diff --git a/packages/sdk/src/internal/types/dto.ts b/packages/sdk/src/internal/types/dto.ts index 6d71859..4eb08ac 100644 --- a/packages/sdk/src/internal/types/dto.ts +++ b/packages/sdk/src/internal/types/dto.ts @@ -35,11 +35,16 @@ export type ActionType = z.infer; export const DriftKindSchema = z.enum(["none", "local", "remote", "both"]); export type DriftKind = z.infer; +export const PlanReadinessImpactSchema = z.enum(["none", "non_blocking", "blocking"]); +export type PlanReadinessImpact = z.infer; + export const PlannedActionSchema = z.object({ action: ActionTypeSchema, address: ResourceAddressSchema, reason: z.string(), driftKind: DriftKindSchema.optional(), + readinessImpact: PlanReadinessImpactSchema.optional(), + changedPaths: z.array(z.string()).optional(), before: z.record(z.string(), z.unknown()).optional(), after: z.record(z.string(), z.unknown()).optional(), dependencies: z.array(ResourceAddressSchema), diff --git a/packages/sdk/src/internal/types/state.ts b/packages/sdk/src/internal/types/state.ts index 29af96f..2e18a0b 100644 --- a/packages/sdk/src/internal/types/state.ts +++ b/packages/sdk/src/internal/types/state.ts @@ -13,11 +13,19 @@ export interface ResourceState { content_hash: string; desired_hash?: string; desired_comparable_hash?: string; + desired_readiness_baseline?: ResourceReadinessBaseline; remote_hash?: string; remote_snapshot?: unknown; + drift_paths?: string[]; drift_status?: "in_sync" | "drifted" | "missing" | "unchecked"; } +export interface ResourceReadinessBaseline { + operational_hash: string; + description_hash: string; + metadata_hash: string; +} + export interface StateFile { resources: ResourceState[]; } diff --git a/packages/sdk/tests/e2e/drift-live.ts b/packages/sdk/tests/e2e/drift-live.ts index 4f87ebe..9a98d21 100644 --- a/packages/sdk/tests/e2e/drift-live.ts +++ b/packages/sdk/tests/e2e/drift-live.ts @@ -2,7 +2,7 @@ import { mkdir, readFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { spawn } from "bun"; -const repo = new URL("../..", import.meta.url).pathname; +const repo = new URL("../../../..", import.meta.url).pathname; const stamp = new Date() .toISOString() .replace(/[-:.TZ]/g, "") @@ -10,7 +10,7 @@ const stamp = new Date() async function runAgents(args: string[]) { const proc = spawn({ - cmd: ["bun", "run", "bin/agents.ts", ...args], + cmd: ["bun", "run", "packages/cli/bin/agents.ts", ...args], cwd: repo, stdout: "pipe", stderr: "pipe", @@ -129,7 +129,10 @@ agents: const after = await api(base, `/agents/${agentId}`, headers); const ok = agentAction?.action === "update" && - /drift/i.test(agentAction.reason ?? "") && + agentAction?.readinessImpact === "non_blocking" && + Array.isArray(agentAction?.changedPaths) && + agentAction.changedPaths.length === 1 && + agentAction.changedPaths[0] === "description" && after.description === "Agents live drift original agent"; console.log(`qoder live drift validation=${ok ? "passed" : "failed"}`); return ok; @@ -146,8 +149,9 @@ async function validateBailian(): Promise { const dir = `/tmp/${name}`; const configPath = join(dir, "agents.yaml"); const statePath = join(dir, "agents.state.json"); - const base = Bun.env.BAILIAN_BASE_URL; - if (!base) return "skipped"; + const base = + Bun.env.BAILIAN_BASE_URL ?? + `https://${Bun.env.BAILIAN_WORKSPACE_ID}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio`; const headers = { Authorization: `Bearer ${Bun.env.DASHSCOPE_API_KEY}`, "Content-Type": "application/json" }; let agentId: string | undefined; let envId: string | undefined; @@ -238,7 +242,10 @@ agents: const after = await api(base, `/agents/${agentId}`, headers); const ok = agentAction?.action === "update" && - /drift/i.test(agentAction.reason ?? "") && + agentAction?.readinessImpact === "non_blocking" && + Array.isArray(agentAction?.changedPaths) && + agentAction.changedPaths.length === 1 && + agentAction.changedPaths[0] === "description" && after.description === "Agents live drift original agent"; console.log(`bailian live drift validation=${ok ? "passed" : "failed"}`); return ok; diff --git a/packages/sdk/tests/unit/agent-runtime.test.ts b/packages/sdk/tests/unit/agent-runtime.test.ts index 8871ac5..d049395 100644 --- a/packages/sdk/tests/unit/agent-runtime.test.ts +++ b/packages/sdk/tests/unit/agent-runtime.test.ts @@ -174,7 +174,7 @@ describe("agent runtime", () => { expect(readiness.driftSeverity).toBe("blocking"); }); - test("non-blocking drift when only display metadata changed", async () => { + test("uses structured plan impact instead of reason text for non-blocking drift", async () => { const runtime = ctx( baseConfig(), state([ @@ -189,7 +189,8 @@ describe("agent runtime", () => { { action: "update", address: { type: "agent", name: "bailian-cli", provider: "bailian" }, - reason: "Description metadata changed", + reason: "任意展示文案,不参与 readiness 判断", + readinessImpact: "non_blocking", dependencies: [], }, ], diff --git a/packages/sdk/tests/unit/drift-detection.test.ts b/packages/sdk/tests/unit/drift-detection.test.ts index f155b26..06ba207 100644 --- a/packages/sdk/tests/unit/drift-detection.test.ts +++ b/packages/sdk/tests/unit/drift-detection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { buildReadinessBaseline } from "../../src/internal/planner/plan-semantics.ts"; import { buildPlan } from "../../src/internal/planner/planner.ts"; import { refreshState } from "../../src/internal/planner/refresh.ts"; import type { @@ -73,6 +74,7 @@ describe("drift-aware refresh", () => { const refreshed = state.getResource({ type: "agent", name: "assistant", provider: "qoder" })!; expect(refreshed.drift_status).toBe("drifted"); expect(refreshed.remote_hash).toBe(contentHash({ ...desired, description: "remote drift" })); + expect(refreshed.drift_paths).toEqual(["description"]); }); test("keeps provider scoping during refresh", async () => { @@ -117,6 +119,78 @@ describe("Qoder comparable fixtures", () => { }); describe("planner drift classification", () => { + test("marks remote description-only drift as non-blocking without consulting reason text", async () => { + const desired = config.agents!.assistant!; + const desiredHash = contentHash(desired); + const state = StateManager.initialize(tmpPath()); + state.setResource({ + address: { type: "agent", name: "assistant", provider: "qoder" }, + remote_id: "agent_1", + content_hash: desiredHash, + desired_hash: desiredHash, + desired_comparable_hash: desiredHash, + desired_readiness_baseline: buildReadinessBaseline(desired), + }); + + await refreshState(state, new Map([["qoder", fakeProvider({ ...desired, description: "remote drift" })]]), { + config, + }); + const plan = await buildPlan(config, state.getStateFile()); + + expect(plan.actions[0]).toMatchObject({ + action: "update", + driftKind: "remote", + readinessImpact: "non_blocking", + changedPaths: ["description"], + }); + }); + + test("keeps runtime-affecting remote drift blocking", async () => { + const desired = config.agents!.assistant!; + const desiredHash = contentHash(desired); + const state = StateManager.initialize(tmpPath()); + state.setResource({ + address: { type: "agent", name: "assistant", provider: "qoder" }, + remote_id: "agent_1", + content_hash: desiredHash, + desired_hash: desiredHash, + desired_comparable_hash: desiredHash, + desired_readiness_baseline: buildReadinessBaseline(desired), + }); + + await refreshState(state, new Map([["qoder", fakeProvider({ ...desired, model: "different" })]]), { config }); + const plan = await buildPlan(config, state.getStateFile()); + + expect(plan.actions[0]).toMatchObject({ + action: "update", + readinessImpact: "blocking", + changedPaths: ["model"], + }); + }); + + test("marks a local description-only change as non-blocking when the state has a readiness baseline", async () => { + const previous = { ...config.agents!.assistant!, description: "previous" }; + const plan = await buildPlan(config, { + resources: [ + { + address: { type: "agent", name: "assistant", provider: "qoder" }, + remote_id: "agent_1", + content_hash: contentHash(previous), + desired_hash: contentHash(previous), + desired_readiness_baseline: buildReadinessBaseline(previous), + drift_status: "in_sync", + }, + ], + }); + + expect(plan.actions[0]).toMatchObject({ + action: "update", + driftKind: "local", + readinessImpact: "non_blocking", + changedPaths: ["description"], + }); + }); + test("plans update for remote drift without local yaml changes", async () => { const desiredHash = contentHash(config.agents!.assistant!); const plan = await buildPlan(config, { diff --git a/packages/sdk/tests/unit/plan-semantics.test.ts b/packages/sdk/tests/unit/plan-semantics.test.ts new file mode 100644 index 0000000..988dd0d --- /dev/null +++ b/packages/sdk/tests/unit/plan-semantics.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { + buildReadinessBaseline, + classifyReadinessImpact, + diffChangedPaths, + diffReadinessBaseline, +} from "../../src/internal/planner/plan-semantics.ts"; + +describe("plan semantics", () => { + test("reason-independent impact keeps descriptive fields non-blocking", () => { + expect(classifyReadinessImpact("update", ["description", "metadata.owner"])).toBe("non_blocking"); + expect(classifyReadinessImpact("update", ["description", "model"])).toBe("blocking"); + expect(classifyReadinessImpact("create", ["description"])).toBe("blocking"); + }); + + test("builds an irreversible readiness baseline without retaining declaration values", () => { + const baseline = buildReadinessBaseline({ + description: "public label", + metadata: { owner: "team" }, + credentials: { token: "must-not-enter-state" }, + }); + + expect(Object.values(baseline).every((value) => /^[a-f0-9]{64}$/.test(value))).toBe(true); + expect(JSON.stringify(baseline)).not.toContain("must-not-enter-state"); + expect(JSON.stringify(baseline)).not.toContain("public label"); + }); + + test("derives local change facets from hashed baselines", () => { + const before = buildReadinessBaseline({ description: "old", metadata: { owner: "a" }, model: "m1" }); + const descriptive = buildReadinessBaseline({ description: "new", metadata: { owner: "a" }, model: "m1" }); + const operational = buildReadinessBaseline({ description: "old", metadata: { owner: "a" }, model: "m2" }); + + expect(diffReadinessBaseline(before, descriptive)).toEqual(["description"]); + expect(diffReadinessBaseline(before, operational)).toEqual(["$operational"]); + }); + + test("derives stable remote paths independent of object key order", () => { + expect(diffChangedPaths({ metadata: { a: 1, b: 2 } }, { metadata: { b: 2, a: 1 } })).toEqual([]); + expect(diffChangedPaths({ metadata: { a: 1 } }, { metadata: { a: 2 } })).toEqual(["metadata.a"]); + }); +}); diff --git a/scripts/open-source.test.ts b/scripts/open-source.test.ts index 644c612..feb9781 100644 --- a/scripts/open-source.test.ts +++ b/scripts/open-source.test.ts @@ -150,6 +150,7 @@ describe("open-source repository invariants", () => { expect(workflow).toContain('git config user.name "github-actions[bot]"'); expect(workflow).toContain("github-actions[bot]@users.noreply.github.com"); expect(workflow).toContain("registry-ready:"); + expect(workflow).toMatch(/registry-ready:[\s\S]*?timeout-minutes: 20/); expect(workflow).toContain("post-release-consumer:"); expect(workflow).toContain("os: [ubuntu-latest, windows-latest, macos-latest]"); expect(workflow).toContain("node: [22, 24]"); diff --git a/scripts/release/consumer-smoke.test.ts b/scripts/release/consumer-smoke.test.ts index 8b4c58b..77fcbf5 100644 --- a/scripts/release/consumer-smoke.test.ts +++ b/scripts/release/consumer-smoke.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { commonRegistryVersion, packageName, registryVersion } from "./consumer-smoke.ts"; +import { commonRegistryVersion, packageName, registryVersion, waitForRegistry } from "./consumer-smoke.ts"; describe("published consumer smoke", () => { test("builds canonical package names", () => { @@ -8,7 +8,9 @@ describe("published consumer smoke", () => { test("reads an exact registry version", () => { expect(registryVersion('"1.2.3"', "@openagentpack/sdk")).toBe("1.2.3"); - expect(() => registryVersion("[]", "@openagentpack/sdk")).toThrow("invalid version"); + const invalidVersion = () => registryVersion("[]", "@openagentpack/sdk"); + expect(invalidVersion).toThrow("invalid version for @openagentpack/sdk"); + expect(invalidVersion).toThrow("received: []"); }); test("requires the fixed release group to resolve one version", () => { @@ -25,4 +27,71 @@ describe("published consumer smoke", () => { ]), ).toThrow("versions must match"); }); + + test("waits through transient registry propagation", async () => { + let queries = 0; + const delays: number[] = []; + const version = await waitForRegistry( + "1.2.3", + { attempts: 3, delayMs: 25 }, + { + resolve: () => { + queries++; + if (queries < 3) throw new Error("@openagentpack/sdk is not visible yet"); + return "1.2.3"; + }, + sleep: async (delayMs) => { + delays.push(delayMs); + }, + log: () => {}, + }, + ); + + expect(version).toBe("1.2.3"); + expect(queries).toBe(3); + expect(delays).toEqual([25, 25]); + }); + + test("reports the transient registry error while waiting", async () => { + let queries = 0; + const messages: string[] = []; + await waitForRegistry( + "1.2.3", + { attempts: 2, delayMs: 0 }, + { + resolve: () => { + queries++; + if (queries === 1) throw new Error("@openagentpack/sdk returned []"); + return "1.2.3"; + }, + sleep: async () => {}, + log: (message) => messages.push(message), + }, + ); + + expect(messages[0]).toContain("@openagentpack/sdk returned []"); + }); + + test("allows fifteen minutes for registry propagation by default", async () => { + let queries = 0; + let delays = 0; + const waiting = waitForRegistry( + "1.2.3", + {}, + { + resolve: () => { + queries++; + throw new Error("not visible"); + }, + sleep: async () => { + delays++; + }, + log: () => {}, + }, + ); + + await expect(waiting).rejects.toThrow("not visible"); + expect(queries).toBe(90); + expect(delays).toBe(89); + }); }); diff --git a/scripts/release/consumer-smoke.ts b/scripts/release/consumer-smoke.ts index 42fd2e1..49ec94a 100644 --- a/scripts/release/consumer-smoke.ts +++ b/scripts/release/consumer-smoke.ts @@ -25,7 +25,8 @@ export function registryVersion(raw: string, name: string): string { } catch { // Fall through to the stable error below. } - throw new Error(`npm registry returned an invalid version for ${name}`); + const summary = raw.trim().replaceAll(/\s+/g, " ").slice(0, 200) || ""; + throw new Error(`npm registry returned an invalid version for ${name}; received: ${summary}`); } export function commonRegistryVersion(entries: ReadonlyArray<{ name: string; version: string }>): string { @@ -46,11 +47,14 @@ function run(command: string[], cwd: string, stdout: "inherit" | "pipe" = "inher } function npmVersion(name: string, requested: string): string { - const result = Bun.spawnSync(["npm", "view", `${name}@${requested}`, "version", "--json", "--registry", REGISTRY], { - cwd: root, - stdout: "pipe", - stderr: "pipe", - }); + const result = Bun.spawnSync( + ["npm", "view", `${name}@${requested}`, "version", "--json", "--prefer-online", "--registry", REGISTRY], + { + cwd: root, + stdout: "pipe", + stderr: "pipe", + }, + ); if (result.exitCode !== 0) { throw new Error(result.stderr.toString().trim() || `${name}@${requested} is not visible in the npm registry`); } @@ -72,20 +76,29 @@ export function resolvePublishedVersion(requested: string): string { export async function waitForRegistry( requested: string, options: { attempts?: number; delayMs?: number } = {}, + dependencies: { + resolve?: (requested: string) => string; + sleep?: (delayMs: number) => Promise; + log?: (message: string) => void; + } = {}, ): Promise { - const attempts = options.attempts ?? 30; + const attempts = options.attempts ?? 90; const delayMs = options.delayMs ?? 10_000; + const resolve = dependencies.resolve ?? resolvePublishedVersion; + const sleep = dependencies.sleep ?? Bun.sleep; + const log = dependencies.log ?? console.log; let lastError: unknown; for (let attempt = 1; attempt <= attempts; attempt++) { try { - const version = resolvePublishedVersion(requested); - console.log(`✓ All published packages are visible at ${version}`); + const version = resolve(requested); + log(`✓ All published packages are visible at ${version}`); return version; } catch (error) { lastError = error; if (attempt === attempts) break; - console.log(`Registry not ready (${attempt}/${attempts}); retrying in ${delayMs / 1000}s...`); - await Bun.sleep(delayMs); + const reason = error instanceof Error ? error.message : String(error); + log(`Registry not ready (${attempt}/${attempts}): ${reason}; retrying in ${delayMs / 1000}s...`); + await sleep(delayMs); } } throw lastError;