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
12 changes: 12 additions & 0 deletions .changeset/project-identity-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"agent-bundle": minor
---

Derive project package identity from `package.json` (#94 Wave 1 stages 1–2).

Normalized models, artifact manifests, inspect results, and development
source status now expose validated `packageName`/`packageVersion`.
`plugin.version` still authors the native plugin version but no longer
silently wins: a mismatch warns (`AB4008`) and `package.json` remains
authoritative. `plugin.name` is unchanged (G9). Unpackaged or unversioned
projects receive the labeled `0.0.0-dev` development fallback.
2 changes: 2 additions & 0 deletions examples/audiobook-curator/agent-bundle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export default defineConfig({
description:
'Complete plan-first audiobook inventory, matching, conversion, repair, and integrity audit.',
name: 'audiobook-curator',
// Package identity is derived from package.json. plugin.name stays the
// host slug; plugin.version must match until a later stage makes it optional.
version: '1.0.0',
},
runtime: { node: '22.19.0' },
Expand Down
21 changes: 20 additions & 1 deletion packages/agent-bundle/src/build/manifest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { digest, stableJson } from '../core/digest.ts';
import { isPackageName, isSemanticPackageVersion } from '../core/project-context.ts';
import {
formatRuntimeVersion,
parseRuntimeVersion,
Expand Down Expand Up @@ -43,6 +44,8 @@ export interface ArtifactManifestProject {
readonly configDigest: string;
readonly configPath: string;
readonly modelDigest: string;
readonly packageName?: string;
readonly packageVersion?: string;
readonly revision: string;
readonly sourceInputs: readonly ArtifactManifestSourceInput[];
}
Expand Down Expand Up @@ -307,7 +310,7 @@ const validateManifest = (value: unknown): ArtifactManifest => {
if (producer.name !== 'agent-bundle') fail('producer.name must be "agent-bundle".');

const project = requireRecord(manifest.project, 'project');
requireExactKeys(project, 'project', ['configDigest', 'configPath', 'modelDigest', 'revision', 'sourceInputs']);
requireExactKeys(project, 'project', ['configDigest', 'configPath', 'modelDigest', 'revision', 'sourceInputs'], ['packageName', 'packageVersion']);
const sourceInputs = parseSourceInputs(project.sourceInputs, 'project.sourceInputs');
const configPath = requirePath(project.configPath, 'project.configPath');
const configDigest = requireHash(project.configDigest, 'project.configDigest');
Expand All @@ -317,6 +320,21 @@ const validateManifest = (value: unknown): ArtifactManifest => {
}
const revision = requireHash(project.revision, 'project.revision');
if (revision !== digest({ inputs: sourceInputs })) fail('project.revision does not match project.sourceInputs.');
const packageName = project.packageName === undefined
? undefined
: requireString(project.packageName, 'project.packageName');
const packageVersion = project.packageVersion === undefined
? undefined
: requireString(project.packageVersion, 'project.packageVersion');
if ((packageName === undefined) !== (packageVersion === undefined)) {
fail('project must include both packageName and packageVersion, or neither.');
}
if (packageName !== undefined && !isPackageName(packageName)) {
fail('project.packageName must be a nonempty package name.');
}
if (packageVersion !== undefined && !isSemanticPackageVersion(packageVersion)) {
fail('project.packageVersion must be a semantic version.');
}

const files = parseFiles(manifest.files);
const projectInputPaths = new Set(sourceInputs.map((input) => input.path));
Expand Down Expand Up @@ -354,6 +372,7 @@ const validateManifest = (value: unknown): ArtifactManifest => {
configDigest,
configPath,
modelDigest: requireHash(project.modelDigest, 'project.modelDigest'),
...(packageName === undefined ? {} : { packageName, packageVersion }),
revision,
sourceInputs,
},
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-bundle/src/config/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { basename, extname, relative, resolve } from 'node:path';

import { digest } from '../core/digest.ts';
import { isInside } from '../core/paths.ts';
import { derivePackageIdentity } from '../core/project-context.ts';
import {
defaultGeneratedRuntime,
formatRuntimeVersion,
Expand Down Expand Up @@ -779,6 +780,7 @@ export const normalizeProject = async (
const scripts = normalizeScripts(loaded, targetNames);
const assets = normalizeAssets(loaded, discovered, targetNames);
const packageBuild = normalizePackageBuild(loaded.config, loaded.context.projectRoot, loaded.configPath);
const packageIdentity = derivePackageIdentity(loaded.context.projectRoot);
const model: NormalizedPlugin = {
...(assets.length === 0 ? {} : { assets }),
...(loaded.config.marketplace === true ? { marketplace: true as const } : {}),
Expand All @@ -795,6 +797,8 @@ export const normalizeProject = async (
hooks: normalizeHooks(loaded, targetNames, registry, payloads),
...(nativeHooks.length === 0 ? {} : { nativeHooks }),
...(packageBuild === undefined ? {} : { packageBuild }),
packageName: packageIdentity.packageName,
packageVersion: packageIdentity.packageVersion,
...(payloads.length === 0 ? {} : { payloads }),
runtime: normalizeRuntime(loaded),
scripts,
Expand Down
156 changes: 152 additions & 4 deletions packages/agent-bundle/src/core/project-context.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { realpathSync } from 'node:fs';
import { readFileSync, realpathSync } from 'node:fs';
import { isAbsolute, relative, resolve } from 'node:path';

import { digest } from './digest.ts';
import type { Diagnostic } from './diagnostics.ts';
import { digest, sha256Hex } from './digest.ts';
import { isErrno } from './errors.ts';
import { deepFreeze } from './freeze.ts';
import { isInsideOrEqual } from './paths.ts';
import { snapshotStrictJsonValue } from './strict-json.ts';
import { parseSemanticVersion } from './semver.ts';
import { isRecord, snapshotStrictJsonValue } from './strict-json.ts';
import type { NormalizedPlugin, SourceProvenance } from './types.ts';

/** One deterministic, byte-addressed authored input in a project identity. */
Expand All @@ -25,13 +28,16 @@ export interface ProjectContext {
readonly configDigest: string;
readonly configPath: string;
readonly modelDigest: string;
readonly packageName?: string;
readonly packageVersion?: string;
readonly revision: string;
readonly sourceInputs: readonly ProjectSourceInput[];
}

export interface CreateProjectContextOptions {
readonly configPath: string;
readonly model: NormalizedPlugin;
readonly requirePackageIdentity?: boolean;
readonly root: string;
readonly sourceInputs: readonly ProjectSourceSnapshotInput[];
}
Expand Down Expand Up @@ -283,11 +289,151 @@ const canonicalSourceInputs = (
return deepFreeze(canonical);
};

export interface ProjectPackageJsonSnapshot {
readonly identity?: {
readonly packageName: string;
readonly packageVersion: string;
};
readonly packageName?: string;
readonly sha256: string;
}

/** Labeled development fallback used when package.json has no valid name. */
export const fallbackDevPackageName = 'agent-bundle-dev';

/** Labeled development fallback used when package.json has no valid version. */
export const fallbackDevPackageVersion = '0.0.0-dev';

export interface DerivedPackageIdentity {
readonly packageName: string;
readonly packageVersion: string;
readonly source: 'dev-fallback' | 'package.json';
}

const packageVersionPattern =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u;
Comment on lines +313 to +314

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject invalid prerelease identifiers

For package versions containing an invalid SemVer prerelease, such as 1.0.0-01, 1.0.0-., or 1.0.0-alpha..1, this pattern returns true because it treats the entire prerelease as an unrestricted character run; parseSemanticVersion() applies the same loose rule. These values are therefore recorded as authoritative semantic package versions and accepted by artifact-manifest validation instead of receiving the development fallback or being rejected. Validate dot-separated identifiers, including the no-empty-identifiers and no-leading-zero numeric rules.

Useful? React with 👍 / 👎.


export const isPackageName = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0 && value.trim() === value;

export const isSemanticPackageVersion = (value: unknown): value is string =>
typeof value === 'string' &&
packageVersionPattern.test(value) &&
parseSemanticVersion(value) !== undefined;

const invalidPackageIdentity = (root: string): never => {
throw new TypeError(
`Project package.json in ${JSON.stringify(root)} must declare a nonempty name and valid semantic version.`,
);
};

export const readProjectPackageJson = (root: string): ProjectPackageJsonSnapshot | undefined => {
const packageJsonPath = resolve(root, 'package.json');
let bytes: string;
try {
bytes = readFileSync(packageJsonPath, 'utf8');
} catch (error) {
if (isErrno(error, 'ENOENT')) return undefined;
throw new TypeError(`Project package.json ${JSON.stringify(packageJsonPath)} could not be read.`);
}
const sha256 = sha256Hex(bytes);
let parsed: unknown;
try {
parsed = JSON.parse(bytes);
} catch {
throw new TypeError(`Project package.json ${JSON.stringify(packageJsonPath)} must be valid JSON.`);
}
if (!isRecord(parsed)) {
throw new TypeError(`Project package.json ${JSON.stringify(packageJsonPath)} must be a JSON object.`);
}
const packageName = isPackageName(parsed.name) ? parsed.name : undefined;
const packageVersion = isSemanticPackageVersion(parsed.version) ? parsed.version : undefined;
return {
sha256,
...(packageName === undefined ? {} : { packageName }),
...(packageName === undefined || packageVersion === undefined
? {}
: { identity: { packageName, packageVersion } }),
};
};

const derivedIdentityFromSnapshot = (
snapshot: ProjectPackageJsonSnapshot | undefined,
): DerivedPackageIdentity => {
if (snapshot?.identity !== undefined) {
return {
packageName: snapshot.identity.packageName,
packageVersion: snapshot.identity.packageVersion,
source: 'package.json',
};
}
if (snapshot?.packageName !== undefined) {
return {
packageName: snapshot.packageName,
packageVersion: fallbackDevPackageVersion,
source: 'dev-fallback',
};
}
return {
packageName: fallbackDevPackageName,
packageVersion: fallbackDevPackageVersion,
source: 'dev-fallback',
};
};

/** Resolves package.json identity, or the labeled development fallback. */
export const derivePackageIdentity = (root: string): DerivedPackageIdentity =>
derivedIdentityFromSnapshot(readProjectPackageJson(root));

export const packageVersionMismatchDiagnostic = (
pluginVersion: string,
packageVersion: string,
sourcePath: string,
): Diagnostic | undefined => {
if (pluginVersion === packageVersion) return undefined;
return {
code: 'AB4008',
message:
`plugin.version ${JSON.stringify(pluginVersion)} differs from package.json version ${JSON.stringify(packageVersion)}; package.json is authoritative.`,
recovery:
'Keep package.json version as the package identity and update plugin.version to match. plugin.version does not override package.json.',
severity: 'warning',
sourcePath,
};
};

const withPackageJsonSourceInput = (
root: string,
inputs: readonly ProjectSourceSnapshotInput[],
packageSnapshot: ProjectPackageJsonSnapshot | undefined,
): readonly ProjectSourceSnapshotInput[] => {
if (packageSnapshot === undefined) return inputs;
const packageJsonPath = resolve(root, 'package.json');
const canonicalPath = resolvedProjectPath(root, packageJsonPath, 'Package manifest path');
const alreadyDeclared = inputs.some((input) => {
try {
return resolvedProjectPath(root, input.path, 'Project source input path') === canonicalPath;
} catch {
return false;
}
});
if (alreadyDeclared) return inputs;
return [...inputs, { path: packageJsonPath, sha256: packageSnapshot.sha256 }];
Comment on lines +420 to +421

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep package.json in every freshness snapshot

When .gitignore excludes package.json (for example via *.json), snapshotProjectSource() omits it, but this branch adds it to ProjectContext.sourceInputs anyway. ArtifactService.build() later compares that context with a fresh snapshot using exact array equality, so every Workbench build is rejected with AB7101 even when no source changed. Force package.json into snapshotProjectSource() as well so preparation and freshness checks use the same input set.

Useful? React with 👍 / 👎.

};

/** Creates the single canonical identity carried from preparation to publication. */
export const createProjectContext = (options: CreateProjectContextOptions): ProjectContext => {
const canonicalRoot = realpathSync(resolve(options.root));
const configPath = resolvedProjectPath(canonicalRoot, options.configPath, 'Configuration path');
const sourceInputs = canonicalSourceInputs(options.root, options.sourceInputs);
const packageSnapshot = readProjectPackageJson(canonicalRoot);
const derived = derivedIdentityFromSnapshot(packageSnapshot);
if (options.requirePackageIdentity === true && derived.source !== 'package.json') {
invalidPackageIdentity(canonicalRoot);
}
const sourceInputs = canonicalSourceInputs(
options.root,
withPackageJsonSourceInput(canonicalRoot, options.sourceInputs, packageSnapshot),
);
const configInput = sourceInputs.find((input) => input.path === configPath);
if (configInput === undefined) {
throw new TypeError(`Configuration source ${JSON.stringify(configPath)} must have a SHA-256 digest.`);
Expand All @@ -297,6 +443,8 @@ export const createProjectContext = (options: CreateProjectContextOptions): Proj
configDigest: configInput.sha256,
configPath,
modelDigest: digest(canonicalizeNormalizedModel(canonicalRoot, options.model)),
packageName: derived.packageName,
packageVersion: derived.packageVersion,
revision: digest({ inputs: sourceInputs }),
sourceInputs,
});
Expand Down
10 changes: 10 additions & 0 deletions packages/agent-bundle/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,16 @@ export interface NormalizedPlugin {
* models predating the package build stay valid.
*/
readonly packageBuild?: NormalizedPackageBuild;
/**
* Derived package.json name, or omitted for unpackaged scratch projects.
* plugin.name stays the host-facing plugin identity and is never overwritten.
*/
readonly packageName?: string;
/**
* Derived package.json version, or the labeled development fallback
* `0.0.0-dev` when package.json names a package without a valid version.
*/
readonly packageVersion?: string;
/**
* Declared prebuilt payload directories packaged verbatim. Present only
* when the config declares a `payload` block; optional so hand-constructed
Expand Down
6 changes: 6 additions & 0 deletions packages/agent-bundle/src/dev/agent-api-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export type AgentApiBuildStatus =

export interface AgentApiSourceStatus {
readonly diagnostics: readonly AgentApiDiagnostic[];
readonly packageName?: string;
readonly packageVersion?: string;
readonly revision?: string;
readonly state: 'invalid' | 'ready' | 'unknown';
}
Expand Down Expand Up @@ -243,8 +245,12 @@ const sourceWireDto = (value: unknown): AgentApiSourceStatus => {
const source = snapshotRecord(value);
const state = source?.state;
const revision = safeDigest(source?.revision);
const packageName = typeof source?.packageName === 'string' && source.packageName.length > 0 ? source.packageName : undefined;
const packageVersion = typeof source?.packageVersion === 'string' && source.packageVersion.length > 0 ? source.packageVersion : undefined;
return Object.freeze({
diagnostics: diagnosticWireDtos(source?.diagnostics),
...(packageName === undefined ? {} : { packageName }),
...(packageVersion === undefined ? {} : { packageVersion }),
...(revision === undefined ? {} : { revision }),
state: state === 'invalid' || state === 'ready' || state === 'unknown' ? state : 'unknown',
});
Expand Down
6 changes: 5 additions & 1 deletion packages/agent-bundle/src/dev/agent-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ interface AgentApiDiagnostic {
interface AgentApiProjectStatus {
readonly artifact: unknown;
readonly build: unknown;
readonly source: Readonly<{ readonly diagnostics: readonly AgentApiDiagnostic[]; readonly revision?: string; readonly state: string }>;
readonly source: Readonly<{ readonly diagnostics: readonly AgentApiDiagnostic[]; readonly packageName?: string; readonly packageVersion?: string; readonly revision?: string; readonly state: string }>;
}

/** Durable, path-free acknowledgement returned when an eval background job is admitted. */
Expand Down Expand Up @@ -493,8 +493,12 @@ const sourceWireDto = (value: unknown): AgentApiProjectStatus['source'] => {
const source = snapshotRecord(value);
const state = source?.state;
const revision = safeDigest(source?.revision);
const packageName = typeof source?.packageName === 'string' && source.packageName.length > 0 ? source.packageName : undefined;
const packageVersion = typeof source?.packageVersion === 'string' && source.packageVersion.length > 0 ? source.packageVersion : undefined;
return Object.freeze({
diagnostics: diagnosticWireDtos(source?.diagnostics),
...(packageName === undefined ? {} : { packageName }),
...(packageVersion === undefined ? {} : { packageVersion }),
...(revision === undefined ? {} : { revision }),
state: state === 'invalid' || state === 'ready' || state === 'unknown' ? state : 'unknown',
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,8 @@ export class ArtifactInspectionService {
configDigest: project.configDigest,
configPath: project.configPath,
modelDigest: project.modelDigest,
...(project.packageName === undefined ? {} : { packageName: project.packageName }),
...(project.packageVersion === undefined ? {} : { packageVersion: project.packageVersion }),
revision: project.revision,
sourceInputs: Object.freeze(inputs as ArtifactInspectionSourceInput[]),
});
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/dev/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ const withDiagnostics = (
build: { state: 'idle' },
source: {
diagnostics: freezeDiagnostics([...source.diagnostics, ...diagnostics]),
...(source.packageName === undefined ? {} : { packageName: source.packageName }),
...(source.packageVersion === undefined ? {} : { packageVersion: source.packageVersion }),
...(source.revision === undefined ? {} : { revision: source.revision }),
state: hasErrors([...source.diagnostics, ...diagnostics]) ? 'invalid' : source.state,
},
Expand Down
Loading
Loading