Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/bright-graphs-navigate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/agent": minor
---

Add `PACKAGE_INVENTORY_PROTOCOL`, `packageInventorySchema`, and the public package-inventory types for validating versioned working-tree and immutable-bundle agent inventories.
5 changes: 5 additions & 0 deletions .changeset/calm-graphs-navigate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/harness": minor
---

Project dependency graphs now open immediately and stay on their fast path. Agents appear as soon as a project is selected and fill in their real names in the background, and a project whose graph is otherwise fine no longer shows "Graph may be incomplete" because one agent's source could not be read. That agent is still called out on its own; the rest of the graph is cached. Graph navigation targets are now served with the revision they belong to, so opening an agent from the map lands on the right one.
11 changes: 11 additions & 0 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ export type { StepInputContract, AgentInputContract } from './introspection.js';
export { MANIFEST_PROTOCOL, agentManifestSchema } from './manifest.js';
export type { AgentManifest, AgentStepManifest, ManifestTransition } from './manifest.js';

// Multi-agent package inventory — separate from the single-agent build manifest.
export { PACKAGE_INVENTORY_PROTOCOL, packageInventorySchema } from './package-inventory.js';
export type {
PackageInventory,
PackageInventoryAgent,
PackageInventoryIdentityIssue,
PackageInventoryJsonValue,
PackageInventoryStaticSignals,
PackageInventoryVersion,
} from './package-inventory.js';

// Manifest generator + graph validation — called by the build phase.
export { buildManifest, validateGraph, assertValidGraph } from './build-manifest.js';
export type { GraphValidation } from './build-manifest.js';
366 changes: 366 additions & 0 deletions packages/agent/src/package-inventory.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,366 @@
import {
PACKAGE_INVENTORY_PROTOCOL,
packageInventorySchema,
type PackageInventory,
type PackageInventoryIdentityIssue,
type PackageInventoryJsonValue,
type PackageInventoryStaticSignals,
} from "./index.js";

const SHA_A = `sha256:${"a".repeat(64)}` as const;
const SHA_B = `sha256:${"b".repeat(64)}` as const;

function workingTree(
overrides: Partial<PackageInventory> = {},
): PackageInventory {
return {
protocol: PACKAGE_INVENTORY_PROTOCOL,
version: {
kind: "working-tree",
workspaceKey: "workspace-acme",
revision: SHA_A,
},
status: "complete",
agents: [
{
agentKey: "Research",
identityStatus: "canonical",
path: "agents/research",
entrypoint: "index.ts",
},
],
...overrides,
};
}

describe("packageInventorySchema", () => {
it("parses working-tree and bundle inventories and sorts agents deterministically", () => {
const agents = [
{
agentKey: "zeta",
identityStatus: "canonical" as const,
path: "zeta",
entrypoint: "index.ts",
},
{
agentKey: "Alpha",
identityStatus: "canonical" as const,
path: ".",
entrypoint: "src/index.ts",
staticSignals: {
protocol: 1,
payload: { nested: [true, null, 3] },
},
},
];
const working = packageInventorySchema.parse(
workingTree({ agents: [...agents].reverse() }),
);
const equivalent = packageInventorySchema.parse(workingTree({ agents }));

expect(working.agents.map((agent) => agent.agentKey)).toEqual([
"Alpha",
"zeta",
]);
expect(working.agents).toEqual(equivalent.agents);
expect(
packageInventorySchema.parse({
protocol: 1,
version: { kind: "bundle", bundleDigest: SHA_B },
status: "complete",
agents,
}).version,
).toEqual({ kind: "bundle", bundleDigest: SHA_B });
});

it("accepts a degraded working tree with a safe provisional identity", () => {
expect(
packageInventorySchema.parse(
workingTree({
status: "degraded",
agents: [
{
agentKey: "local:agents/research",
identityStatus: "provisional",
identityIssue: "identity-pending",
path: "agents/research",
entrypoint: "index.ts",
},
],
}),
).status,
).toBe("degraded");
});

it.each(["local:C:", "local:C:/agent"])(
"rejects Windows drive-shaped provisional key %j",
(agentKey) => {
expect(() =>
packageInventorySchema.parse(
workingTree({
status: "degraded",
agents: [
{
agentKey,
identityStatus: "provisional",
identityIssue: "identity-unavailable",
path: "agent",
entrypoint: "index.ts",
},
],
}),
),
).toThrow();
},
);

it.each([
"",
" agent",
"agent ",
".",
"..",
"agents/research",
"agents\\research",
"/absolute",
"local:reserved",
"control\nname",
"control\u0085name",
"control\u009fname",
])("rejects unsafe canonical name %j", (agentKey) => {
expect(() =>
packageInventorySchema.parse(
workingTree({
agents: [
{
agentKey,
identityStatus: "canonical",
path: ".",
entrypoint: "index.ts",
},
],
}),
),
).toThrow();
});

it.each([
"sha256:ABCDEF",
`sha256:${"A".repeat(64)}`,
`sha256:${"a".repeat(63)}`,
"a".repeat(64),
])("rejects invalid digest %j", (revision) => {
expect(() =>
packageInventorySchema.parse(
workingTree({
version: {
kind: "working-tree",
workspaceKey: "workspace-acme",
revision: revision as `sha256:${string}`,
},
}),
),
).toThrow();
});

it.each([
{ path: "", entrypoint: "index.ts" },
{ path: "/absolute", entrypoint: "index.ts" },
{ path: "C:/absolute", entrypoint: "index.ts" },
{ path: "agents\\research", entrypoint: "index.ts" },
{ path: "agents/../research", entrypoint: "index.ts" },
{ path: ".", entrypoint: "../index.ts" },
{ path: ".", entrypoint: "/index.ts" },
{ path: ".", entrypoint: "C:/index.ts" },
{ path: ".", entrypoint: "." },
])(
"rejects unsafe relative location $path/$entrypoint",
({ path, entrypoint }) => {
expect(() =>
packageInventorySchema.parse(
workingTree({
agents: [
{
agentKey: "research",
identityStatus: "canonical",
path,
entrypoint,
},
],
}),
),
).toThrow();
},
);

it("rejects duplicate keys and duplicate path/entrypoint pairs", () => {
const base = workingTree().agents[0]!;
expect(() =>
packageInventorySchema.parse(
workingTree({ agents: [base, { ...base, path: "copy" }] }),
),
).toThrow(/Duplicate agentKey/);
expect(() =>
packageInventorySchema.parse(
workingTree({ agents: [base, { ...base, agentKey: "growth" }] }),
),
).toThrow(/Duplicate agent path/);
});

it("rejects complete provisional inventories and every degraded bundle", () => {
const provisional = workingTree({
status: "degraded",
agents: [
{
agentKey: "local:research",
identityStatus: "provisional",
identityIssue: "identity-unavailable",
path: "research",
entrypoint: "index.ts",
},
],
});
expect(() =>
packageInventorySchema.parse({ ...provisional, status: "complete" }),
).toThrow(/provisional identities must be degraded/);
expect(() =>
packageInventorySchema.parse({
...provisional,
version: { kind: "bundle", bundleDigest: SHA_B },
}),
).toThrow(/bundle inventory/);
expect(() =>
packageInventorySchema.parse({
...workingTree({ status: "degraded" }),
version: { kind: "bundle", bundleDigest: SHA_B },
}),
).toThrow(/bundle inventory/);
});

it("accepts degraded working trees with canonical-only or empty rows", () => {
expect(
packageInventorySchema.parse(workingTree({ status: "degraded" })).status,
).toBe("degraded");
expect(
packageInventorySchema.parse(
workingTree({ status: "degraded", agents: [] }),
).agents,
).toEqual([]);
});

it("requires duplicate provisional records to retain their safe candidate", () => {
const duplicate = {
agentKey: "local:research-a",
identityStatus: "provisional" as const,
identityIssue: "duplicate-agent-key" as const,
path: "research-a",
entrypoint: "index.ts",
};
expect(() =>
packageInventorySchema.parse(
workingTree({ status: "degraded", agents: [duplicate] as never }),
),
).toThrow(/ambiguous candidate/);
expect(
packageInventorySchema.parse(
workingTree({
status: "degraded",
agents: [{ ...duplicate, candidateAgentKey: "research" }],
}),
).agents[0]?.candidateAgentKey,
).toBe("research");
});

it.each([
{
agentKey: "research",
identityStatus: "canonical",
identityIssue: "identity-pending",
path: "research",
entrypoint: "index.ts",
},
{
agentKey: "research",
identityStatus: "canonical",
candidateAgentKey: "candidate",
path: "research",
entrypoint: "index.ts",
},
{
agentKey: "local:research",
identityStatus: "canonical",
path: "research",
entrypoint: "index.ts",
},
{
agentKey: "local:research",
identityStatus: "provisional",
path: "research",
entrypoint: "index.ts",
},
{
agentKey: "local:research",
identityStatus: "provisional",
identityIssue: "identity-pending",
candidateAgentKey: "research",
path: "research",
entrypoint: "index.ts",
},
])("rejects inconsistent identity state %#", (agent) => {
expect(() =>
packageInventorySchema.parse(
workingTree({ status: "degraded", agents: [agent] as never }),
),
).toThrow();
});

it.each([
{ protocol: 0, payload: null },
{ protocol: 1.5, payload: null },
{ protocol: 1, payload: Number.NaN },
{ protocol: 1, payload: Number.POSITIVE_INFINITY },
{ protocol: 1, payload: undefined },
{ protocol: 1, payload: () => undefined },
{ protocol: 1, payload: BigInt(1) },
])("rejects invalid static signals %#", (staticSignals) => {
expect(() =>
packageInventorySchema.parse(
workingTree({
agents: [
{
agentKey: "research",
identityStatus: "canonical",
path: "research",
entrypoint: "index.ts",
staticSignals,
},
] as never,
}),
),
).toThrow();
});
});

describe("package inventory public type surface", () => {
// Both of these are reachable from exported types — `PackageInventoryAgent`
// is defined in terms of the issue union, and `staticSignals.payload` is the
// JSON type — so a consumer that cannot name them has to re-declare them by
// hand, and that copy diverges the moment a later protocol adds a reason.
it("names every type a consumer needs to handle an inventory", () => {
const reasons: PackageInventoryIdentityIssue[] = [
"identity-pending",
"identity-unavailable",
"identity-invalid",
"duplicate-agent-key",
];
const payload: PackageInventoryJsonValue = {
calls: ["billing", { name: "payments", async: true }],
depth: 2,
unknown: null,
};
const signals: PackageInventoryStaticSignals = { protocol: 1, payload };

expect(reasons).toHaveLength(4);
expect(signals.payload).toBe(payload);
});
});
Loading
Loading