Skip to content

Commit ba1af83

Browse files
authored
Improve plan readiness semantics and npm release resilience (#20)
* fix: tolerate npm registry propagation delays [policy] Change-Id: I2fda5eedb254b1b2caa0a624b45a96d496db530d * feat: classify plan readiness impact Change-Id: I329bd0b0f21f7ded2bf21671cdead6a264aab0f0
1 parent d213ad3 commit ba1af83

21 files changed

Lines changed: 378 additions & 24 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@openagentpack/sdk": patch
3+
---
4+
5+
Classify Agent readiness from structured plan impact and changed paths instead of parsing display text.

.github/workflows/release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ jobs:
121121
registry-ready:
122122
needs: [preflight, publish]
123123
runs-on: ubuntu-latest
124+
timeout-minutes: 20
124125
permissions:
125126
contents: read
126127
steps:

apps/server/openapi.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,16 @@
803803
"type": "string",
804804
"enum": ["none", "local", "remote", "both"]
805805
},
806+
"readinessImpact": {
807+
"type": "string",
808+
"enum": ["none", "non_blocking", "blocking"]
809+
},
810+
"changedPaths": {
811+
"type": "array",
812+
"items": {
813+
"type": "string"
814+
}
815+
},
806816
"before": {
807817
"type": "object",
808818
"additionalProperties": {

apps/webui/src/lib/api/generated/schema.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,9 @@ export interface paths {
298298
reason: string;
299299
/** @enum {string} */
300300
driftKind?: "none" | "local" | "remote" | "both";
301+
/** @enum {string} */
302+
readinessImpact?: "none" | "non_blocking" | "blocking";
303+
changedPaths?: string[];
301304
before?: {
302305
[key: string]: unknown;
303306
};

packages/sdk/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ export {
192192
ListSessionsRequestSchema,
193193
ListSessionsResponseSchema,
194194
PlannedActionSchema,
195+
PlanReadinessImpactSchema,
195196
ResourceAddressSchema,
196197
ResourceTypeSchema,
197198
SendEventRequestSchema,
@@ -232,6 +233,7 @@ export type {
232233
ListSessionsRequest,
233234
ListSessionsResponse,
234235
PlannedAction,
236+
PlanReadinessImpact,
235237
ResourceAddress,
236238
ResourceType,
237239
SendEventRequest,

packages/sdk/src/internal/core/agent-runtime.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -474,9 +474,7 @@ function actionKey(action: PlannedAction): string {
474474

475475
function isNonBlockingAgentDrift(action: PlannedAction): boolean {
476476
if (action.action === "no-op") return true;
477-
if (action.action !== "update") return false;
478-
const reason = action.reason.toLowerCase();
479-
return reason.includes("metadata") || reason.includes("description");
477+
return action.readinessImpact === "non_blocking";
480478
}
481479

482480
export function collectAgentAddresses(config: ProjectConfig, agentName: string, provider?: string): ResourceAddress[] {

packages/sdk/src/internal/core/resource-runtime.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { UserError } from "../errors.ts";
22
import { type ExecutionResult, executePlan } from "../executor/executor.ts";
3+
import { getResourceDeclaration } from "../planner/declaration.ts";
4+
import { buildReadinessBaseline } from "../planner/plan-semantics.ts";
35
import { buildPlan } from "../planner/planner.ts";
46
import { type RefreshResult, refreshState } from "../planner/refresh.ts";
57
import type { ExecutionPlan, PlannedAction } from "../types/plan.ts";
@@ -126,6 +128,7 @@ export async function importResource(
126128
version: options.resourceVersion,
127129
content_hash: contentHash,
128130
desired_hash: contentHash,
131+
desired_readiness_baseline: buildReadinessBaseline(getResourceDeclaration(address, ctx.config)),
129132
};
130133
ctx.state.setResource(resource);
131134
await ctx.state.save();

packages/sdk/src/internal/executor/executor.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { dirname, resolve } from "node:path";
22
import { UserError } from "../errors.ts";
33
import { computeComparableDesiredHash } from "../planner/comparable.ts";
4+
import { getResourceDeclaration } from "../planner/declaration.ts";
45
import { computeResourceHash } from "../planner/hasher.ts";
6+
import { buildReadinessBaseline } from "../planner/plan-semantics.ts";
57
import { ApiError, ConflictError } from "../providers/base-client.ts";
68
import { readComparableIfSupported } from "../providers/drift-support.ts";
79
import type { RemoteResource } from "../providers/interface.ts";
@@ -454,8 +456,10 @@ async function executeAction(action: PlannedAction, provider: ResourceExecAdapte
454456
content_hash: hash,
455457
desired_hash: hash,
456458
desired_comparable_hash: remoteHash,
459+
desired_readiness_baseline: buildReadinessBaseline(getResourceDeclaration(address, ctx.config)),
457460
remote_hash: remoteHash,
458461
remote_snapshot: remoteSnapshot,
462+
drift_paths: [],
459463
drift_status: remoteHash ? "in_sync" : undefined,
460464
});
461465
return adopted;
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { ActionType, PlanReadinessImpact } from "../types/dto.ts";
2+
import type { ResourceReadinessBaseline } from "../types/state.ts";
3+
import { contentHash } from "../utils/hash.ts";
4+
5+
const NON_BLOCKING_ROOT_FIELDS = new Set(["description", "metadata"]);
6+
7+
/**
8+
* Return stable, leaf-oriented paths whose values differ between two JSON-like
9+
* resource snapshots. Arrays are treated atomically because their ordering is
10+
* part of the declared resource semantics.
11+
*/
12+
export function diffChangedPaths(before: unknown, after: unknown, prefix = ""): string[] {
13+
if (Object.is(before, after)) return [];
14+
if (Array.isArray(before) || Array.isArray(after)) {
15+
return structurallyEqual(before, after) ? [] : [prefix || "$root"];
16+
}
17+
if (isRecord(before) && isRecord(after)) {
18+
const paths: string[] = [];
19+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
20+
for (const key of [...keys].sort()) {
21+
const path = prefix ? `${prefix}.${key}` : key;
22+
paths.push(...diffChangedPaths(before[key], after[key], path));
23+
}
24+
return paths;
25+
}
26+
return [prefix || "$root"];
27+
}
28+
29+
/**
30+
* Classify whether an already-provisioned Agent Harness can keep running while
31+
* a planned action is pending. Unknown changes are deliberately blocking.
32+
*/
33+
export function classifyReadinessImpact(
34+
action: ActionType,
35+
changedPaths: readonly string[] | undefined,
36+
): PlanReadinessImpact {
37+
if (action === "no-op") return "none";
38+
if (action !== "update" || !changedPaths || changedPaths.length === 0) return "blocking";
39+
return changedPaths.every(isNonBlockingPath) ? "non_blocking" : "blocking";
40+
}
41+
42+
/** Store only irreversible hashes in state; declarations may contain secrets. */
43+
export function buildReadinessBaseline(declaration: unknown): ResourceReadinessBaseline {
44+
const record = isRecord(declaration) ? declaration : {};
45+
const { description: _description, metadata: _metadata, ...operational } = record;
46+
return {
47+
operational_hash: contentHash(operational),
48+
description_hash: contentHash(record.description ?? null),
49+
metadata_hash: contentHash(record.metadata ?? null),
50+
};
51+
}
52+
53+
export function diffReadinessBaseline(before: ResourceReadinessBaseline, after: ResourceReadinessBaseline): string[] {
54+
const paths: string[] = [];
55+
if (before.operational_hash !== after.operational_hash) paths.push("$operational");
56+
if (before.description_hash !== after.description_hash) paths.push("description");
57+
if (before.metadata_hash !== after.metadata_hash) paths.push("metadata");
58+
return paths;
59+
}
60+
61+
function isNonBlockingPath(path: string): boolean {
62+
const root = path.split(".", 1)[0];
63+
return root !== undefined && NON_BLOCKING_ROOT_FIELDS.has(root);
64+
}
65+
66+
function isRecord(value: unknown): value is Record<string, unknown> {
67+
return typeof value === "object" && value !== null && !Array.isArray(value);
68+
}
69+
70+
function structurallyEqual(left: unknown, right: unknown): boolean {
71+
return contentHash(left) === contentHash(right);
72+
}

packages/sdk/src/internal/planner/planner.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import type { ProjectConfig } from "../types/config.ts";
99
import type { ExecutionPlan, PlannedAction } from "../types/plan.ts";
1010
import type { ResourceAddress, StateFile } from "../types/state.ts";
1111
import { addressKey } from "../types/state.ts";
12+
import { getResourceDeclaration } from "./declaration.ts";
1213
import { computeResourceHash } from "./hasher.ts";
14+
import { buildReadinessBaseline, classifyReadinessImpact, diffReadinessBaseline } from "./plan-semantics.ts";
1315

1416
export interface PlanOptions {
1517
providers?: string[];
@@ -48,6 +50,7 @@ export async function buildPlan(
4850
action: "create",
4951
address,
5052
driftKind: "none",
53+
readinessImpact: "blocking",
5154
reason: "Resource does not exist in state",
5255
after: { content_hash: desiredHash },
5356
dependencies: deps,
@@ -56,10 +59,13 @@ export async function buildPlan(
5659
(existing.desired_hash ?? existing.content_hash) !== desiredHash &&
5760
existing.drift_status === "drifted"
5861
) {
62+
const changedPaths = collectChangedPaths(address, config, existing, true);
5963
actions.push({
6064
action: "update",
6165
address,
6266
driftKind: "both",
67+
readinessImpact: classifyReadinessImpact("update", changedPaths),
68+
changedPaths,
6369
reason: "Local config changed and remote drift detected",
6470
before: {
6571
content_hash: existing.desired_hash ?? existing.content_hash,
@@ -70,20 +76,26 @@ export async function buildPlan(
7076
dependencies: deps,
7177
});
7278
} else if ((existing.desired_hash ?? existing.content_hash) !== desiredHash) {
79+
const changedPaths = collectChangedPaths(address, config, existing, false);
7380
actions.push({
7481
action: "update",
7582
address,
7683
driftKind: "local",
84+
readinessImpact: classifyReadinessImpact("update", changedPaths),
85+
changedPaths,
7786
reason: "Local config changed",
7887
before: { content_hash: existing.desired_hash ?? existing.content_hash },
7988
after: { content_hash: desiredHash },
8089
dependencies: deps,
8190
});
8291
} else if (existing.drift_status === "drifted") {
92+
const changedPaths = existing.drift_paths;
8393
actions.push({
8494
action: "update",
8595
address,
8696
driftKind: "remote",
97+
readinessImpact: classifyReadinessImpact("update", changedPaths),
98+
changedPaths,
8799
reason: "Remote drift detected",
88100
before: {
89101
content_hash: existing.desired_hash ?? existing.content_hash,
@@ -98,6 +110,7 @@ export async function buildPlan(
98110
action: "no-op",
99111
address,
100112
driftKind: "none",
113+
readinessImpact: "none",
101114
reason:
102115
existing.drift_status === "unchecked"
103116
? "No changes detected (remote content drift unchecked)"
@@ -116,6 +129,7 @@ export async function buildPlan(
116129
action: "delete",
117130
address: res.address,
118131
driftKind: "none",
132+
readinessImpact: "blocking",
119133
reason: "Resource removed from configuration",
120134
before: { content_hash: res.desired_hash ?? res.content_hash },
121135
dependencies: [],
@@ -125,6 +139,21 @@ export async function buildPlan(
125139
return { actions, diagnostics: diagnostics.getAll() };
126140
}
127141

142+
function collectChangedPaths(
143+
address: ResourceAddress,
144+
config: ProjectConfig,
145+
existing: StateFile["resources"][number],
146+
includeRemote: boolean,
147+
): string[] | undefined {
148+
const current = buildReadinessBaseline(getResourceDeclaration(address, config));
149+
const localPaths = existing.desired_readiness_baseline
150+
? diffReadinessBaseline(existing.desired_readiness_baseline, current)
151+
: undefined;
152+
if (!includeRemote) return localPaths;
153+
if (!localPaths && !existing.drift_paths) return undefined;
154+
return [...new Set([...(localPaths ?? []), ...(existing.drift_paths ?? [])])].sort();
155+
}
156+
128157
function getDependencies(address: ResourceAddress, graph: ReturnType<typeof buildDependencyGraph>): ResourceAddress[] {
129158
const key = addressKey(address);
130159
const depKeys = graph.edges.get(key) ?? new Set();

0 commit comments

Comments
 (0)