Release Evolver v2 beta v2.0.0-beta.10 from 004fb3374d2c

This commit is contained in:
EvoMap Release Bot
2026-07-23 09:26:14 +00:00
parent f9506cc77f
commit 5fcac11c69
34 changed files with 2684 additions and 202 deletions
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@evomap/evolver",
"version": "2.0.0-beta.9",
"version": "2.0.0-beta.10",
"private": false,
"type": "module",
"description": "Evolver v2 beta public distribution package.",
@@ -31,13 +31,13 @@
"node": ">=22"
},
"dependencies": {
"@evomap/evolver-cli": "2.0.0-beta.9",
"@evomap/evolver-mcp": "2.0.0-beta.9",
"@evomap/evolver-proxy": "2.0.0-beta.9",
"@evomap/evolver-core": "2.0.0-beta.9",
"@evomap/evolver-adapter-public": "2.0.0-beta.9",
"@evomap/evolver-runtime-adapters": "2.0.0-beta.9",
"@evomap/evolver-webui": "2.0.0-beta.9"
"@evomap/evolver-cli": "2.0.0-beta.10",
"@evomap/evolver-mcp": "2.0.0-beta.10",
"@evomap/evolver-proxy": "2.0.0-beta.10",
"@evomap/evolver-core": "2.0.0-beta.10",
"@evomap/evolver-adapter-public": "2.0.0-beta.10",
"@evomap/evolver-runtime-adapters": "2.0.0-beta.10",
"@evomap/evolver-webui": "2.0.0-beta.10"
},
"publishConfig": {
"access": "public",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@evomap/evolver-adapter-public",
"version": "2.0.0-beta.9",
"version": "2.0.0-beta.10",
"private": false,
"type": "module",
"description": "公版 hub 适配器 (积分/治理)",
@@ -14,7 +14,7 @@
},
"dependencies": {
"@evomap/atp-sdk": "^0.1.0",
"@evomap/evolver-core": "2.0.0-beta.9",
"@evomap/evolver-core": "2.0.0-beta.10",
"undici": "^6.27.0"
},
"optionalDependencies": {
+2
View File
@@ -56,6 +56,8 @@ export declare function createRecipeHubFromEnv(env?: NodeJS.ProcessEnv, connectH
hub: PublicHubCapability;
auth: hubNs.AuthProvider;
}): PublicHubCapability;
/** Opaque identity for resume isolation; raw credentials, account ids, and local paths never leave this helper. */
export declare function resolveRecipeHubResumeIdentityFingerprint(env: NodeJS.ProcessEnv): string;
export declare function parseRecipeArgs(argv: readonly string[]): ParseResult<RecipeOptions>;
/**
* The EXPLICIT home overrides (EVOMAP_DIR / EVOLVER_HOME / EVOMAP_HOME) the recipe credential layer honors,
+25
View File
@@ -1,4 +1,5 @@
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { assetstore, events, mailbox } from '@evomap/evolver-core';
@@ -96,6 +97,30 @@ export function createRecipeHubFromEnv(env = process.env, connectHub = connectPu
: connectHub({ hubUrl, authMode: 'oauth', evomapDir: credentials.evomapDir, senderId: () => credentials.senderId });
return connected.hub;
}
/** Opaque identity for resume isolation; raw credentials, account ids, and local paths never leave this helper. */
export function resolveRecipeHubResumeIdentityFingerprint(env) {
loadEnvFileFromEnv(env);
const credentials = resolveRecipeHubCredentials(env);
let credential;
if (credentials.nodeSecret) {
credential = credentials.nodeSecret;
}
else {
try {
credential = createHash('sha256')
.update(readFileSync(join(credentials.evomapDir, 'token.json'), 'utf8'))
.digest('hex');
}
catch {
throw new Error('Hub resume identity credentials are unavailable');
}
}
return createHash('sha256').update(JSON.stringify({
authMode: credentials.nodeSecret ? 'legacy' : 'oauth',
senderId: credentials.senderId ?? '',
credential,
})).digest('hex');
}
function resolveRecipeHubUrl(env) {
return resolveHubUrl(env);
}
+12
View File
@@ -21,6 +21,18 @@ export interface SyncCliDeps {
hub: PublicHubCapability;
auth: hubNs.AuthProvider;
};
connectPrivateHub?: (env: NodeJS.ProcessEnv) => Promise<SyncAccountAssetHub>;
/** Opaque identity supplied by an injected Hub adapter; it is hashed again before any checkpoint write. */
resumeIdentityFingerprint?: string;
}
export declare function runSyncCommand(argv: readonly string[], deps?: SyncCliDeps): Promise<number>;
export declare function preparePrivateSyncHub(runtime: {
hub: unknown;
hello(opts: {
rotate: boolean;
evolverVersion?: string;
}): Promise<{
ok: boolean;
}>;
}): Promise<SyncAccountAssetHub>;
export {};
+1135 -142
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@evomap/evolver-cli",
"version": "2.0.0-beta.9",
"version": "2.0.0-beta.10",
"private": false,
"type": "module",
"description": "evolver CLI (status/watch/cycles/rebuild-views/...)",
@@ -20,12 +20,12 @@
}
},
"dependencies": {
"@evomap/evolver-core": "2.0.0-beta.9",
"@evomap/evolver-adapter-public": "2.0.0-beta.9",
"@evomap/evolver-mcp": "2.0.0-beta.9",
"@evomap/evolver-proxy": "2.0.0-beta.9",
"@evomap/evolver-runtime-adapters": "2.0.0-beta.9",
"@evomap/evolver-webui": "2.0.0-beta.9"
"@evomap/evolver-core": "2.0.0-beta.10",
"@evomap/evolver-adapter-public": "2.0.0-beta.10",
"@evomap/evolver-mcp": "2.0.0-beta.10",
"@evomap/evolver-proxy": "2.0.0-beta.10",
"@evomap/evolver-runtime-adapters": "2.0.0-beta.10",
"@evomap/evolver-webui": "2.0.0-beta.10"
},
"repository": {
"type": "git",
@@ -1,4 +1,4 @@
import type { AssetSyncRecord } from './assetSyncLedger.js';
import type { AssetSyncInventorySegmentRecord, AssetSyncRecord, AssetSyncRunRecord } from './assetSyncLedger.js';
import type { ProvenanceRecord } from './provenance.js';
import type { ReviewRecord } from './reviewLedger.js';
export type AssetSidecarKind = 'provenance' | 'review' | 'asset-sync';
@@ -20,4 +20,9 @@ export declare function parseSidecarJsonl<T>(raw: string, parseRecord: (value: u
export declare function assertTrustSidecarHealthy<T>(sidecar: Extract<AssetSidecarKind, 'provenance' | 'review'>, parsed: ParsedSidecarJsonl<T>): void;
export declare function parseProvenanceRecord(value: unknown): ProvenanceRecord | null;
export declare function parseReviewRecord(value: unknown): ReviewRecord | null;
export declare function parseAssetSyncRecord(value: unknown): AssetSyncRecord | null;
export declare function parseAssetSyncRecord(value: unknown): AssetSyncRecord | null;
export declare const ASSET_SYNC_INVENTORY_MAX_SEGMENT_BYTES: number;
export type AssetSyncSidecarRecord = AssetSyncRecord | AssetSyncRunRecord | AssetSyncInventorySegmentRecord;
export declare function parseAssetSyncRunRecord(value: unknown): AssetSyncRunRecord | null;
export declare function parseAssetSyncInventorySegmentRecord(value: unknown): AssetSyncInventorySegmentRecord | null;
export declare function parseAssetSyncSidecarRecord(value: unknown): AssetSyncSidecarRecord | null;
@@ -116,6 +116,10 @@ export function parseAssetSyncRecord(value) {
}
const logicalId = stringField(record, 'logicalId');
const status = stringField(record, 'status');
const runKey = optionalStrictString(record, 'runKey');
const inventoryKey = optionalStrictString(record, 'inventoryKey');
if (runKey === null || inventoryKey === null)
return null;
const forced = record['forced'] === true;
const collisionWithAssetId = stringField(record, 'collisionWithAssetId');
return {
@@ -125,12 +129,223 @@ export function parseAssetSyncRecord(value) {
scope,
syncedAt,
remoteAssetId,
...(runKey ? { runKey } : {}),
...(inventoryKey ? { inventoryKey } : {}),
...(logicalId ? { logicalId } : {}),
...(status ? { status } : {}),
...(forced ? { forced: true } : {}),
...(collisionWithAssetId ? { collisionWithAssetId } : {}),
};
}
const MAX_INVENTORY_SEGMENTS = 24;
const MAX_INVENTORY_ITEMS_PER_SEGMENT = 10_000;
const MAX_INVENTORY_STRING_LENGTH = 4096;
export const ASSET_SYNC_INVENTORY_MAX_SEGMENT_BYTES = 2 * 1024 * 1024;
export function parseAssetSyncRunRecord(value) {
const record = objectRecord(value);
if (!record || record['recordType'] !== 'run')
return null;
const runId = optionalStrictString(record, 'runId');
const runKey = optionalStrictString(record, 'runKey');
const state = optionalStrictString(record, 'state');
const currentTimestamp = optionalStrictString(record, 'syncedAt');
const legacyTimestamp = optionalStrictString(record, 'at');
if (!runId
|| !runKey
|| state === null
|| !isRunState(state)
|| currentTimestamp === null
|| legacyTimestamp === null
|| (currentTimestamp && legacyTimestamp && currentTimestamp !== legacyTimestamp)) {
return null;
}
const syncedAt = currentTimestamp ?? legacyTimestamp;
if (!syncedAt || Number.isNaN(Date.parse(syncedAt)))
return null;
const remoteAssetId = optionalStrictString(record, 'remoteAssetId');
const outcomeValue = optionalStrictString(record, 'outcome');
const reason = optionalStrictString(record, 'reason');
if (remoteAssetId === null || outcomeValue === null || reason === null)
return null;
const outcome = isRunOutcome(outcomeValue) ? outcomeValue : undefined;
if (outcomeValue !== undefined && !outcome)
return null;
let plan;
const rawPlan = record['plan'];
if (rawPlan !== undefined) {
if (!Array.isArray(rawPlan) || rawPlan.length === 0)
return null;
const normalized = rawPlan.map((entry) => typeof entry === 'string' ? entry.trim() : '');
if (normalized.some((entry, index) => !entry || entry !== rawPlan[index]))
return null;
if (new Set(normalized).size !== normalized.length)
return null;
plan = Object.freeze(normalized);
}
if (state === 'started' && (remoteAssetId || outcome || reason))
return null;
if (state === 'progress') {
if (!remoteAssetId || !outcome || plan)
return null;
if ((outcome === 'failed') !== Boolean(reason))
return null;
}
if (state === 'completed' && (remoteAssetId || outcome || reason || plan))
return null;
return immutableRunRecord({
recordType: 'run',
runId,
runKey,
state,
...(remoteAssetId ? { remoteAssetId } : {}),
...(outcome ? { outcome } : {}),
...(reason ? { reason } : {}),
...(plan ? { plan } : {}),
syncedAt,
});
}
export function parseAssetSyncInventorySegmentRecord(value) {
const record = objectRecord(value);
if (!record || record['recordType'] !== 'inventory_scan')
return null;
try {
if (Buffer.byteLength(JSON.stringify(record), 'utf8') > ASSET_SYNC_INVENTORY_MAX_SEGMENT_BYTES)
return null;
}
catch {
return null;
}
const scanId = strictBoundedString(record['scanId']);
const inventoryKey = strictBoundedString(record['inventoryKey']);
const scope = strictBoundedString(record['scope']);
const syncedAt = strictBoundedString(record['syncedAt']);
const index = record['index'];
const inputCursorFingerprints = parseInventoryCursorFingerprints(record['inputCursorFingerprints']);
const nextCursorFingerprints = parseInventoryCursorFingerprints(record['nextCursorFingerprints']);
const anonymousBlocked = record['anonymousBlocked'];
const cursorHeld = record['cursorHeld'];
const batchId = record['batchId'];
const batchIndex = record['batchIndex'];
const batchSize = record['batchSize'];
const rawItems = record['items'];
const hasBatchMetadata = batchId !== undefined || batchIndex !== undefined || batchSize !== undefined;
if (!scanId
|| !inventoryKey
|| (scope !== 'purchased' && scope !== 'published' && scope !== 'all')
|| !syncedAt
|| Number.isNaN(Date.parse(syncedAt))
|| !Number.isInteger(index)
|| index < 0
|| index >= MAX_INVENTORY_SEGMENTS
|| !inputCursorFingerprints
|| !nextCursorFingerprints
|| !Number.isInteger(anonymousBlocked)
|| anonymousBlocked < 0
|| anonymousBlocked > MAX_INVENTORY_ITEMS_PER_SEGMENT
|| !Array.isArray(rawItems)
|| rawItems.length > MAX_INVENTORY_ITEMS_PER_SEGMENT
|| (cursorHeld !== undefined && cursorHeld !== true)
|| (hasBatchMetadata && (!strictBoundedString(batchId)
|| !Number.isInteger(batchIndex)
|| !Number.isInteger(batchSize)
|| batchSize < 2
|| batchSize > MAX_INVENTORY_SEGMENTS
|| batchIndex < 0
|| batchIndex >= batchSize
|| index - batchIndex < 0
|| index + (batchSize - batchIndex) > MAX_INVENTORY_SEGMENTS))) {
return null;
}
if (scope === 'purchased'
&& (inputCursorFingerprints.published !== null || nextCursorFingerprints.published !== null))
return null;
if (cursorHeld === true
&& (inputCursorFingerprints.purchased !== nextCursorFingerprints.purchased
|| inputCursorFingerprints.published !== nextCursorFingerprints.published))
return null;
if (scope === 'published'
&& (inputCursorFingerprints.purchased !== null || nextCursorFingerprints.purchased !== null))
return null;
if (index === 0
&& (inputCursorFingerprints.purchased !== null || inputCursorFingerprints.published !== null))
return null;
const items = [];
const seen = new Set();
for (const rawItem of rawItems) {
const item = objectRecord(rawItem);
if (!item)
return null;
const remoteAssetId = strictBoundedString(item['remoteAssetId']);
const outcome = strictBoundedString(item['outcome']);
if (!remoteAssetId || !isInventoryOutcome(outcome) || seen.has(remoteAssetId))
return null;
seen.add(remoteAssetId);
items.push(Object.freeze({ remoteAssetId, outcome }));
}
if (items.length + anonymousBlocked > MAX_INVENTORY_ITEMS_PER_SEGMENT)
return null;
return Object.freeze({
recordType: 'inventory_scan',
scanId,
inventoryKey,
scope,
index: index,
inputCursorFingerprints,
nextCursorFingerprints,
items: Object.freeze(items),
anonymousBlocked: anonymousBlocked,
...(cursorHeld === true ? { cursorHeld: true } : {}),
...(hasBatchMetadata ? {
batchId: batchId,
batchIndex: batchIndex,
batchSize: batchSize,
} : {}),
syncedAt,
});
}
export function parseAssetSyncSidecarRecord(value) {
const record = objectRecord(value);
if (!record)
return null;
if (record['recordType'] !== undefined) {
if (record['recordType'] === 'run')
return parseAssetSyncRunRecord(record);
if (record['recordType'] === 'inventory_scan')
return parseAssetSyncInventorySegmentRecord(record);
return null;
}
return parseAssetSyncRecord(record);
}
function parseInventoryCursorFingerprints(value) {
const record = objectRecord(value);
if (!record || !Object.hasOwn(record, 'purchased') || !Object.hasOwn(record, 'published'))
return null;
const purchased = strictNullableCursorFingerprint(record['purchased']);
const published = strictNullableCursorFingerprint(record['published']);
if (purchased === undefined || published === undefined)
return null;
return Object.freeze({ purchased, published });
}
function strictNullableCursorFingerprint(value) {
if (value === null)
return null;
return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) ? value : undefined;
}
function strictBoundedString(value) {
return typeof value === 'string'
&& value.length <= MAX_INVENTORY_STRING_LENGTH
&& value.trim()
&& value === value.trim()
? value
: undefined;
}
function isInventoryOutcome(value) {
return value === 'imported'
|| value === 'already_local'
|| value === 'blocked'
|| value === 'failed'
|| value === 'pending';
}
function objectRecord(value) {
return value && typeof value === 'object' && !Array.isArray(value)
? value
@@ -139,4 +354,23 @@ function objectRecord(value) {
function stringField(value, key) {
const raw = value[key];
return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
}
function optionalStrictString(value, key) {
const raw = value[key];
if (raw === undefined)
return undefined;
return typeof raw === 'string' && raw.trim() && raw === raw.trim() ? raw : null;
}
function immutableRunRecord(record) {
const plan = record.plan ? Object.freeze([...record.plan]) : undefined;
return Object.freeze({ ...record, ...(plan ? { plan } : {}) });
}
function isRunState(value) {
return value === 'started' || value === 'progress' || value === 'completed';
}
function isRunOutcome(value) {
return value === 'imported'
|| value === 'already_local'
|| value === 'failed'
|| value === 'remote_missing';
}
@@ -5,7 +5,7 @@ import { TextDecoder } from 'node:util';
import { canonicalize } from '../wire/index.js';
import { LockReleaseError } from '../util/fileLock.js';
import { AssetStoreReadLimitError, assertAssetStoreDirectory, createBufferDurableExclusive, fsyncDirectoryBestEffort, readRegularBuffer, regularFileFingerprint, replaceUtf8Durable, withAssetStoreLock, } from './assetStoreStorage.js';
import { parseAssetSyncRecord, parseProvenanceRecord, parseReviewRecord, } from './assetSidecarRecords.js';
import { parseAssetSyncSidecarRecord, parseProvenanceRecord, parseReviewRecord, } from './assetSidecarRecords.js';
export class AssetSidecarRecoveryError extends Error {
reason;
code = 'ASSET_SIDECAR_RECOVERY_FAILED';
@@ -29,7 +29,7 @@ const SIDECAR_FILES = {
const RECORD_PARSERS = {
provenance: parseProvenanceRecord,
review: parseReviewRecord,
'asset-sync': parseAssetSyncRecord,
'asset-sync': parseAssetSyncSidecarRecord,
};
function isErrno(error, code) {
return typeof error === 'object' && error !== null && error.code === code;
+2 -2
View File
@@ -4,12 +4,12 @@ import { acquireLock, releaseLock } from '../util/fileLock.js';
import { validateWire, verifyAssetId } from '../wire/index.js';
import { LOCAL_ASSET_FILES } from './assetStoreLayout.js';
import { assertOptionalRegularFile, isReliableAssetStoreLockRelease, readUtf8Regular, UnsafeAssetStorePathError, } from './assetStoreStorage.js';
import { parseAssetSyncRecord, parseProvenanceRecord, parseReviewRecord, parseSidecarJsonl, } from './assetSidecarRecords.js';
import { parseAssetSyncSidecarRecord, parseProvenanceRecord, parseReviewRecord, parseSidecarJsonl, } from './assetSidecarRecords.js';
export const DEFAULT_ASSET_HEALTH_MAX_FILE_BYTES = 64 * 1024 * 1024;
const LOCAL_ASSET_SIDECARS = [
{ kind: 'provenance', file: 'provenance.jsonl', parseRecord: parseProvenanceRecord },
{ kind: 'review', file: 'review.jsonl', parseRecord: parseReviewRecord },
{ kind: 'asset-sync', file: 'asset-sync.jsonl', parseRecord: parseAssetSyncRecord },
{ kind: 'asset-sync', file: 'asset-sync.jsonl', parseRecord: parseAssetSyncSidecarRecord },
];
function healthScanLimit(value) {
if (value === undefined || !Number.isFinite(value))
+11 -2
View File
@@ -237,10 +237,19 @@ export function appendUtf8Durable(path, value, opts = {}) {
const parent = dirname(path);
assertAssetStoreDirectory(parent);
const existed = assertOptionalRegularFile(path) !== null;
const fd = openNoFollow(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT, 0o600);
const fd = openNoFollow(path, constants.O_RDWR | constants.O_APPEND | constants.O_CREAT, 0o600);
try {
assertOpenedPathMatches(fd, path, 'asset_file');
writeAll(fd, value);
const stat = fstatSync(fd);
let needsLineSeparator = false;
if (stat.size > 0 && value.length > 0) {
const tail = Buffer.allocUnsafe(1);
const bytesRead = readSync(fd, tail, 0, 1, stat.size - 1);
if (bytesRead !== 1)
throw new Error('asset store tail read made no progress');
needsLineSeparator = tail[0] !== 0x0a;
}
writeAll(fd, needsLineSeparator ? `\n${value}` : value);
(opts.syncFile ?? fsyncSync)(fd);
}
finally {
@@ -1,4 +1,8 @@
import type { AssetKind } from './provider.js';
export declare const ASSET_SYNC_INVENTORY_MAX_SEGMENTS = 24;
export declare const ASSET_SYNC_INVENTORY_MAX_ITEMS_PER_SEGMENT = 10000;
export declare const ASSET_SYNC_INVENTORY_MAX_UNIQUE_ITEMS = 240000;
export declare const ASSET_SYNC_INVENTORY_MAX_TOTAL_BYTES: number;
export type AssetSyncSource = 'hub';
export type AssetSyncScope = 'purchased' | 'published';
export interface AssetSyncRecord {
@@ -8,25 +12,107 @@ export interface AssetSyncRecord {
scope: AssetSyncScope;
syncedAt: string;
remoteAssetId: string;
/** Opaque sync identity hash. Legacy records omit it and are not used for identity-scoped reconciliation. */
runKey?: string;
/** Parameter-independent remote inventory identity used for reconciliation across sync runs. */
inventoryKey?: string;
logicalId?: string;
status?: string;
forced?: true;
collisionWithAssetId?: string;
}
export type AssetSyncRunState = 'started' | 'progress' | 'completed';
export type AssetSyncRunOutcome = 'imported' | 'already_local' | 'failed' | 'remote_missing';
export interface AssetSyncRunRecord {
recordType: 'run';
runId: string;
runKey: string;
state: AssetSyncRunState;
remoteAssetId?: string;
outcome?: AssetSyncRunOutcome;
reason?: string;
/** Ordered candidate IDs selected when this run was started. */
plan?: readonly string[];
syncedAt: string;
}
export interface AssetSyncRunSnapshot extends AssetSyncRunRecord {
readonly processed: ReadonlyMap<string, AssetSyncRunRecord>;
}
export type AssetSyncInventoryOutcome = 'imported' | 'already_local' | 'blocked' | 'failed' | 'pending';
export interface AssetSyncInventoryCursorFingerprints {
readonly purchased: string | null;
readonly published: string | null;
}
export interface AssetSyncInventoryItem {
readonly remoteAssetId: string;
readonly outcome: AssetSyncInventoryOutcome;
}
export interface AssetSyncInventorySegmentRecord {
readonly recordType: 'inventory_scan';
readonly scanId: string;
readonly inventoryKey: string;
readonly scope: 'purchased' | 'published' | 'all';
readonly index: number;
readonly inputCursorFingerprints: AssetSyncInventoryCursorFingerprints;
readonly nextCursorFingerprints: AssetSyncInventoryCursorFingerprints;
readonly items: readonly AssetSyncInventoryItem[];
readonly anonymousBlocked: number;
/** This segment must be replayed from its input cursor before the scan may advance. */
readonly cursorHeld?: true;
/** Multi-segment batches are applied only after every physical segment is present. */
readonly batchId?: string;
readonly batchIndex?: number;
readonly batchSize?: number;
readonly syncedAt: string;
}
export type AssetSyncInventoryBatch = Omit<AssetSyncInventorySegmentRecord, 'recordType' | 'syncedAt' | 'batchId' | 'batchIndex' | 'batchSize'> & {
syncedAt?: string;
};
export interface AssetSyncInventorySnapshot {
readonly scanId: string;
readonly inventoryKey: string;
readonly scope: 'purchased' | 'published' | 'all';
readonly segmentCount: number;
readonly nextCursorFingerprints: AssetSyncInventoryCursorFingerprints;
readonly outcomes: ReadonlyMap<string, AssetSyncInventoryOutcome>;
readonly anonymousBlocked: number;
readonly complete: boolean;
/** Physical segment index whose input cursor must be replayed before this scan can advance. */
readonly retryIndex?: number;
}
export declare class AssetSyncLedger {
private readonly now;
private readonly path;
private readonly lockPath;
private readonly index;
private readonly runKeyIndex;
private readonly inventoryKeyIndex;
private fileState;
private loaded;
constructor(baseDir: string, now?: () => number);
append(rec: Omit<AssetSyncRecord, 'syncedAt'> & {
syncedAt?: string;
}): AssetSyncRecord;
appendRun(rec: Omit<AssetSyncRunRecord, 'recordType' | 'syncedAt'> & {
syncedAt?: string;
}): AssetSyncRunRecord;
appendInventorySegment(rec: Omit<AssetSyncInventorySegmentRecord, 'recordType' | 'syncedAt'> & {
syncedAt?: string;
}): AssetSyncInventorySegmentRecord | null;
appendInventoryBatch(rec: AssetSyncInventoryBatch): readonly AssetSyncInventorySegmentRecord[] | null;
replaceInventoryRetryBatch(rec: AssetSyncInventoryBatch): readonly AssetSyncInventorySegmentRecord[] | null;
clearInventoryScan(inventoryKey: string): void;
runRecords(runId: string): AssetSyncRunRecord[];
latestIncompleteRun(runKey: string): AssetSyncRunSnapshot | undefined;
latestInventoryScan(inventoryKey: string): AssetSyncInventorySnapshot | undefined;
get(assetId: string): AssetSyncRecord | null;
getForRunKey(runKey: string, assetId: string): AssetSyncRecord | null;
list(): AssetSyncRecord[];
listForRunKey(runKey: string): AssetSyncRecord[];
listForInventoryKey(inventoryKey: string): AssetSyncRecord[];
private rebuildIndex;
private refreshUnderLock;
private withFreshRead;
private readRunRecordsUnderLock;
private latestInventorySnapshotUnderLock;
}
+683 -4
View File
@@ -1,11 +1,19 @@
import { randomUUID } from 'node:crypto';
import { dirname, join } from 'node:path';
import { appendUtf8Durable, assertAssetStoreDirectory, ensureAssetStoreDirectory, readUtf8Regular, regularFileFingerprint, withAssetStoreLock, } from './assetStoreStorage.js';
import { parseAssetSyncRecord, parseSidecarJsonl } from './assetSidecarRecords.js';
import { appendUtf8Durable, assertAssetStoreDirectory, ensureAssetStoreDirectory, readUtf8Regular, regularFileFingerprint, replaceUtf8Durable, withAssetStoreLock, } from './assetStoreStorage.js';
import { ASSET_SYNC_INVENTORY_MAX_SEGMENT_BYTES, parseAssetSyncInventorySegmentRecord, parseAssetSyncRecord, parseAssetSyncRunRecord, parseSidecarJsonl, } from './assetSidecarRecords.js';
export const ASSET_SYNC_INVENTORY_MAX_SEGMENTS = 24;
export const ASSET_SYNC_INVENTORY_MAX_ITEMS_PER_SEGMENT = 10_000;
export const ASSET_SYNC_INVENTORY_MAX_UNIQUE_ITEMS = 240_000;
export const ASSET_SYNC_INVENTORY_MAX_TOTAL_BYTES = 48 * 1024 * 1024;
const ASSET_SYNC_SIDECAR_MAX_BYTES = 64 * 1024 * 1024;
export class AssetSyncLedger {
now;
path;
lockPath;
index = new Map();
runKeyIndex = new Map();
inventoryKeyIndex = new Map();
fileState = null;
loaded = false;
constructor(baseDir, now = Date.now) {
@@ -24,27 +32,248 @@ export class AssetSyncLedger {
});
appendUtf8Durable(this.path, `${JSON.stringify(full)}\n`);
this.index.set(full.assetId, full);
if (full.runKey) {
const scoped = this.runKeyIndex.get(full.runKey) ?? new Map();
scoped.set(full.assetId, full);
this.runKeyIndex.set(full.runKey, scoped);
}
if (full.inventoryKey) {
const scoped = this.inventoryKeyIndex.get(full.inventoryKey) ?? new Map();
scoped.set(full.assetId, full);
this.inventoryKeyIndex.set(full.inventoryKey, scoped);
}
this.fileState = regularFileFingerprint(this.path);
return full;
});
}
appendRun(rec) {
assertAssetStoreDirectory(dirname(this.path));
return withAssetStoreLock(this.lockPath, () => {
this.refreshUnderLock();
const full = parseAssetSyncRunRecord({
recordType: 'run',
...rec,
syncedAt: rec.syncedAt ?? new Date(this.now()).toISOString(),
});
if (!full)
throw new Error('invalid asset sync run record');
appendUtf8Durable(this.path, `${JSON.stringify(full)}\n`);
this.fileState = regularFileFingerprint(this.path);
return full;
});
}
appendInventorySegment(rec) {
assertAssetStoreDirectory(dirname(this.path));
return withAssetStoreLock(this.lockPath, () => {
this.refreshUnderLock();
const full = parseAssetSyncInventorySegmentRecord({
recordType: 'inventory_scan',
...rec,
syncedAt: rec.syncedAt ?? new Date(this.now()).toISOString(),
});
if (!full)
return null;
const state = readUtf8Regular(this.path) ?? '';
const line = `${JSON.stringify(full)}\n`;
if (full.index === 0) {
const compacted = removeInventoryScan(state, full.inventoryKey);
const replacement = `${compacted}${line}`;
if (!canAppendInventorySegment(undefined, full)
|| inventoryScanBytes(replacement) > ASSET_SYNC_INVENTORY_MAX_TOTAL_BYTES
|| Buffer.byteLength(replacement, 'utf8') > ASSET_SYNC_SIDECAR_MAX_BYTES) {
return null;
}
replaceUtf8Durable(this.path, replacement);
this.rebuildIndex(regularFileFingerprint(this.path));
return full;
}
const current = this.latestInventorySnapshotUnderLock(full.inventoryKey);
if (!canAppendInventorySegment(current, full))
return null;
if (inventoryScanBytes(state) + Buffer.byteLength(line, 'utf8') > ASSET_SYNC_INVENTORY_MAX_TOTAL_BYTES
|| Buffer.byteLength(state, 'utf8') + Buffer.byteLength(line, 'utf8') > ASSET_SYNC_SIDECAR_MAX_BYTES) {
return null;
}
appendUtf8Durable(this.path, line);
this.fileState = regularFileFingerprint(this.path);
return full;
});
}
appendInventoryBatch(rec) {
assertAssetStoreDirectory(dirname(this.path));
return withAssetStoreLock(this.lockPath, () => {
this.refreshUnderLock();
const records = createInventoryBatchRecords({
...rec,
syncedAt: rec.syncedAt ?? new Date(this.now()).toISOString(),
});
if (!records)
return null;
const state = readUtf8Regular(this.path) ?? '';
const lines = records.map((record) => `${JSON.stringify(record)}\n`).join('');
const current = rec.index === 0 ? undefined : this.latestInventorySnapshotUnderLock(rec.inventoryKey);
if (!canAppendInventoryBatch(current, records))
return null;
if (rec.index === 0) {
const compacted = removeInventoryScan(state, rec.inventoryKey);
const replacement = `${compacted}${lines}`;
if (inventoryScanBytes(replacement) > ASSET_SYNC_INVENTORY_MAX_TOTAL_BYTES
|| Buffer.byteLength(replacement, 'utf8') > ASSET_SYNC_SIDECAR_MAX_BYTES) {
return null;
}
replaceUtf8Durable(this.path, replacement);
this.rebuildIndex(regularFileFingerprint(this.path));
return records;
}
if (inventoryScanBytes(state) + Buffer.byteLength(lines, 'utf8') > ASSET_SYNC_INVENTORY_MAX_TOTAL_BYTES
|| Buffer.byteLength(state, 'utf8') + Buffer.byteLength(lines, 'utf8') > ASSET_SYNC_SIDECAR_MAX_BYTES) {
return null;
}
appendUtf8Durable(this.path, lines);
this.fileState = regularFileFingerprint(this.path);
return records;
});
}
replaceInventoryRetryBatch(rec) {
assertAssetStoreDirectory(dirname(this.path));
return withAssetStoreLock(this.lockPath, () => {
this.refreshUnderLock();
const state = readUtf8Regular(this.path) ?? '';
const current = this.latestInventorySnapshotUnderLock(rec.inventoryKey);
if (!current
|| current.retryIndex === undefined
|| rec.index !== current.retryIndex
|| rec.scanId !== current.scanId
|| rec.scope !== current.scope
|| !cursorFingerprintsEqual(rec.inputCursorFingerprints, current.nextCursorFingerprints))
return null;
const retryTail = findInventoryRetryTail(state, current);
if (!retryTail)
return null;
const mergedItems = mergeInventoryRetryItems(retryTail.records, rec.items);
if (!mergedItems)
return null;
const previousAnonymousBlocked = retryTail.records.reduce((total, record) => total + record.anonymousBlocked, 0);
const records = createInventoryBatchRecords({
...rec,
items: mergedItems,
anonymousBlocked: Math.max(previousAnonymousBlocked, rec.anonymousBlocked),
syncedAt: rec.syncedAt ?? new Date(this.now()).toISOString(),
});
if (!records)
return null;
const preserved = removeInventoryRetryTail(state, retryTail.lineIndexes);
const prefix = inventorySnapshotFromRaw(preserved, rec.inventoryKey);
if (!canAppendInventoryBatch(prefix, records))
return null;
const replacement = `${preserved}${records.map((record) => `${JSON.stringify(record)}\n`).join('')}`;
if (inventoryScanBytes(replacement) > ASSET_SYNC_INVENTORY_MAX_TOTAL_BYTES
|| Buffer.byteLength(replacement, 'utf8') > ASSET_SYNC_SIDECAR_MAX_BYTES)
return null;
replaceUtf8Durable(this.path, replacement);
this.rebuildIndex(regularFileFingerprint(this.path));
return records;
});
}
clearInventoryScan(inventoryKey) {
assertAssetStoreDirectory(dirname(this.path));
withAssetStoreLock(this.lockPath, () => {
this.refreshUnderLock();
const state = readUtf8Regular(this.path) ?? '';
const compacted = removeInventoryScan(state, inventoryKey);
if (compacted === state)
return;
replaceUtf8Durable(this.path, compacted);
this.rebuildIndex(regularFileFingerprint(this.path));
});
}
runRecords(runId) {
assertAssetStoreDirectory(dirname(this.path));
return withAssetStoreLock(this.lockPath, () => {
this.refreshUnderLock();
return this.readRunRecordsUnderLock().filter((record) => record.runId === runId);
});
}
latestIncompleteRun(runKey) {
assertAssetStoreDirectory(dirname(this.path));
return withAssetStoreLock(this.lockPath, () => {
this.refreshUnderLock();
let current;
for (const record of this.readRunRecordsUnderLock()) {
if (record.runKey !== runKey)
continue;
if (record.state === 'started') {
current = Object.freeze({
...record,
processed: current?.runId === record.runId ? current.processed : new Map(),
});
}
if (record.state === 'progress' && record.remoteAssetId && record.outcome) {
if (current?.runId === record.runId) {
const processed = new Map(current.processed);
processed.set(record.remoteAssetId, record);
current = Object.freeze({ ...current, processed });
}
}
if (record.state === 'completed' && current?.runId === record.runId)
current = undefined;
}
return current;
});
}
latestInventoryScan(inventoryKey) {
assertAssetStoreDirectory(dirname(this.path));
return withAssetStoreLock(this.lockPath, () => {
this.refreshUnderLock();
return this.latestInventorySnapshotUnderLock(inventoryKey);
});
}
get(assetId) {
return this.withFreshRead((index) => index.get(assetId) ?? null);
}
getForRunKey(runKey, assetId) {
return this.withFreshRead(() => this.runKeyIndex.get(runKey)?.get(assetId) ?? null);
}
list() {
return this.withFreshRead((index) => [...index.values()]);
}
listForRunKey(runKey) {
return this.withFreshRead(() => [...(this.runKeyIndex.get(runKey)?.values() ?? [])]);
}
listForInventoryKey(inventoryKey) {
return this.withFreshRead(() => [...(this.inventoryKeyIndex.get(inventoryKey)?.values() ?? [])]);
}
rebuildIndex(state) {
const next = new Map();
const nextRunKeyIndex = new Map();
const nextInventoryKeyIndex = new Map();
const raw = state === 'missing' ? null : readUtf8Regular(this.path);
if (raw !== null) {
const parsed = parseSidecarJsonl(raw, parseAssetSyncRecord);
for (const record of parsed.records)
next.set(record.assetId, immutableRecord(record));
for (const parsedRecord of parsed.records) {
const record = immutableRecord(parsedRecord);
next.set(record.assetId, record);
if (record.runKey) {
const scoped = nextRunKeyIndex.get(record.runKey) ?? new Map();
scoped.set(record.assetId, record);
nextRunKeyIndex.set(record.runKey, scoped);
}
if (record.inventoryKey) {
const scoped = nextInventoryKeyIndex.get(record.inventoryKey) ?? new Map();
scoped.set(record.assetId, record);
nextInventoryKeyIndex.set(record.inventoryKey, scoped);
}
}
}
this.index.clear();
for (const [assetId, record] of next)
this.index.set(assetId, record);
this.runKeyIndex.clear();
for (const [runKey, records] of nextRunKeyIndex)
this.runKeyIndex.set(runKey, records);
this.inventoryKeyIndex.clear();
for (const [inventoryKey, records] of nextInventoryKeyIndex)
this.inventoryKeyIndex.set(inventoryKey, records);
this.fileState = state;
this.loaded = true;
}
@@ -60,7 +289,457 @@ export class AssetSyncLedger {
return read(this.index);
});
}
readRunRecordsUnderLock() {
const raw = readUtf8Regular(this.path);
if (raw === null)
return [];
const records = [];
for (const line of raw.split(/\r?\n/)) {
if (!line.trim())
continue;
try {
const record = parseAssetSyncRunRecord(JSON.parse(line));
if (record)
records.push(record);
}
catch {
// Corrupt sidecar lines cannot make all resumable work unreadable.
}
}
return records;
}
latestInventorySnapshotUnderLock(inventoryKey) {
const raw = readUtf8Regular(this.path);
return raw === null ? undefined : inventorySnapshotFromRaw(raw, inventoryKey);
}
}
function inventorySnapshotFromRaw(raw, inventoryKey) {
let current;
let pendingBatch = [];
for (const line of raw.split(/\r?\n/)) {
if (!line.trim())
continue;
const record = parseInventoryLine(line);
if (!record || record.inventoryKey !== inventoryKey)
continue;
if (record.batchId) {
if (record.batchIndex === 0) {
pendingBatch = [record];
}
else if (continuesPendingBatch(pendingBatch, record)) {
pendingBatch.push(record);
}
else {
pendingBatch = [];
}
if (pendingBatch.length === record.batchSize) {
const batch = pendingBatch;
pendingBatch = [];
if (!canAppendInventoryBatch(current, batch)) {
if (current?.scanId === record.scanId)
current = undefined;
continue;
}
current = snapshotFromInventoryBatch(current, batch);
}
continue;
}
pendingBatch = [];
if (record.index === 0) {
current = snapshotFromFirstSegment(record);
continue;
}
if (!current || !canAppendInventorySegment(current, record)) {
if (current?.scanId === record.scanId)
current = undefined;
continue;
}
current = appendSegmentToSnapshot(current, record);
}
return current;
}
function immutableRecord(record) {
return Object.freeze({ ...record });
}
function createInventoryBatchRecords(rec) {
if (!Array.isArray(rec.items) || !Number.isInteger(rec.anonymousBlocked) || rec.anonymousBlocked < 0)
return null;
const syncedAt = rec.syncedAt ?? new Date().toISOString();
const common = parseAssetSyncInventorySegmentRecord({
recordType: 'inventory_scan',
...rec,
items: [],
anonymousBlocked: 0,
syncedAt,
});
const totalItems = rec.items.length + rec.anonymousBlocked;
const remoteAssetIds = new Set();
for (const item of rec.items) {
if (!item || typeof item.remoteAssetId !== 'string' || remoteAssetIds.has(item.remoteAssetId))
return null;
remoteAssetIds.add(item.remoteAssetId);
}
if (!common
|| totalItems > ASSET_SYNC_INVENTORY_MAX_UNIQUE_ITEMS)
return null;
if (splitInventoryUnits(rec.items, rec.anonymousBlocked).length === 1) {
const singleton = parseAssetSyncInventorySegmentRecord({
...common,
items: rec.items,
anonymousBlocked: rec.anonymousBlocked,
});
if (singleton)
return Object.freeze([singleton]);
}
const batchId = randomUUID();
const initialChunks = splitInventoryUnits(rec.items, rec.anonymousBlocked);
const chunks = [];
for (const chunk of initialChunks) {
const remainingSegments = ASSET_SYNC_INVENTORY_MAX_SEGMENTS - rec.index - chunks.length;
const fitted = splitInventoryChunkToFit(common, chunk, batchId, chunks.length, remainingSegments);
if (!fitted)
return null;
chunks.push(...fitted);
}
if (chunks.length === 0
|| chunks.length > ASSET_SYNC_INVENTORY_MAX_SEGMENTS
|| rec.index + chunks.length > ASSET_SYNC_INVENTORY_MAX_SEGMENTS)
return null;
const records = [];
for (const [batchIndex, chunk] of chunks.entries()) {
const parsed = parseAssetSyncInventorySegmentRecord({
...common,
index: rec.index + batchIndex,
items: chunk.items,
anonymousBlocked: chunk.anonymousBlocked,
...(chunks.length > 1 ? { batchId, batchIndex, batchSize: chunks.length } : {}),
});
if (!parsed)
return null;
records.push(parsed);
}
return Object.freeze(records);
}
function splitInventoryUnits(items, anonymousBlocked) {
if (items.length === 0 && anonymousBlocked === 0)
return [{ items: [], anonymousBlocked: 0 }];
const chunks = [];
let itemOffset = 0;
let anonymousRemaining = anonymousBlocked;
while (itemOffset < items.length || anonymousRemaining > 0) {
const itemCount = Math.min(ASSET_SYNC_INVENTORY_MAX_ITEMS_PER_SEGMENT, items.length - itemOffset);
const anonymousCount = Math.min(ASSET_SYNC_INVENTORY_MAX_ITEMS_PER_SEGMENT - itemCount, anonymousRemaining);
chunks.push({
items: items.slice(itemOffset, itemOffset + itemCount),
anonymousBlocked: anonymousCount,
});
itemOffset += itemCount;
anonymousRemaining -= anonymousCount;
}
return chunks;
}
function splitInventoryChunkToFit(common, chunk, batchId, batchIndex, remainingSegments) {
if (remainingSegments < 1)
return null;
const parsed = parseAssetSyncInventorySegmentRecord({
...common,
index: common.index + batchIndex,
items: chunk.items,
anonymousBlocked: chunk.anonymousBlocked,
});
const batchProbe = parsed ? {
...parsed,
batchId,
batchIndex,
batchSize: ASSET_SYNC_INVENTORY_MAX_SEGMENTS,
} : null;
if (batchProbe
&& Buffer.byteLength(JSON.stringify(batchProbe), 'utf8') <= ASSET_SYNC_INVENTORY_MAX_SEGMENT_BYTES)
return [chunk];
const units = chunk.items.length + chunk.anonymousBlocked;
if (units <= 1)
return null;
const leftUnits = Math.floor(units / 2);
const leftItemCount = Math.min(leftUnits, chunk.items.length);
const left = {
items: chunk.items.slice(0, leftItemCount),
anonymousBlocked: leftUnits - leftItemCount,
};
const right = {
items: chunk.items.slice(leftItemCount),
anonymousBlocked: chunk.anonymousBlocked - left.anonymousBlocked,
};
const fittedLeft = splitInventoryChunkToFit(common, left, batchId, batchIndex, remainingSegments);
if (!fittedLeft)
return null;
const fittedRight = splitInventoryChunkToFit(common, right, batchId, batchIndex + fittedLeft.length, remainingSegments - fittedLeft.length);
return fittedLeft && fittedRight ? [...fittedLeft, ...fittedRight] : null;
}
function continuesPendingBatch(pending, record) {
const first = pending[0];
return Boolean(first?.batchId
&& record.batchId === first.batchId
&& record.batchSize === first.batchSize
&& record.batchIndex === pending.length
&& record.index === first.index + pending.length
&& record.scanId === first.scanId
&& record.inventoryKey === first.inventoryKey
&& record.scope === first.scope
&& record.syncedAt === first.syncedAt
&& record.cursorHeld === first.cursorHeld
&& cursorFingerprintsEqual(record.inputCursorFingerprints, first.inputCursorFingerprints)
&& cursorFingerprintsEqual(record.nextCursorFingerprints, first.nextCursorFingerprints));
}
function canAppendInventoryBatch(current, records) {
const first = records[0];
if (!first)
return false;
if (records.length === 1)
return !first.batchId && canAppendInventorySegment(current, first);
if (first.batchIndex !== 0
|| first.batchSize !== records.length
|| !records.every((record, index) => index === 0 || continuesPendingBatch(records.slice(0, index), record))
|| (first.cursorHeld === true && !inventoryBatchHasRetryableOutcome(records)))
return false;
if (first.index === 0) {
if (!cursorFingerprintsEqual(first.inputCursorFingerprints, { purchased: null, published: null }))
return false;
}
else if (!current
|| current.complete
|| current.retryIndex !== undefined
|| current.scanId !== first.scanId
|| current.inventoryKey !== first.inventoryKey
|| current.scope !== first.scope
|| current.segmentCount !== first.index
|| !cursorFingerprintsEqual(current.nextCursorFingerprints, first.inputCursorFingerprints)) {
return false;
}
if ((current?.segmentCount ?? 0) + records.length > ASSET_SYNC_INVENTORY_MAX_SEGMENTS)
return false;
const outcomes = new Set(current?.outcomes.keys() ?? []);
let anonymousBlocked = current?.anonymousBlocked ?? 0;
for (const record of records) {
for (const item of record.items)
outcomes.add(item.remoteAssetId);
anonymousBlocked += record.anonymousBlocked;
}
return outcomes.size + anonymousBlocked <= ASSET_SYNC_INVENTORY_MAX_UNIQUE_ITEMS;
}
function snapshotFromInventoryBatch(current, records) {
const first = records[0];
if (!first)
throw new Error('inventory batch is empty');
const outcomes = new Map(current?.outcomes ?? []);
let anonymousBlocked = current?.anonymousBlocked ?? 0;
for (const record of records) {
for (const item of record.items) {
if (!outcomes.has(item.remoteAssetId))
outcomes.set(item.remoteAssetId, item.outcome);
}
anonymousBlocked += record.anonymousBlocked;
}
const retryIndex = inventoryBatchRetryIndex(first);
return immutableInventorySnapshot({
scanId: first.scanId,
inventoryKey: first.inventoryKey,
scope: first.scope,
segmentCount: (current?.segmentCount ?? 0) + records.length,
nextCursorFingerprints: first.nextCursorFingerprints,
outcomes,
anonymousBlocked,
complete: retryIndex === undefined && inventoryScanComplete(first.scope, first.nextCursorFingerprints),
...(retryIndex === undefined ? {} : { retryIndex }),
});
}
function canAppendInventorySegment(current, record) {
if (record.cursorHeld === true && !inventoryBatchHasRetryableOutcome([record]))
return false;
if (record.index === 0) {
return cursorFingerprintsEqual(record.inputCursorFingerprints, { purchased: null, published: null });
}
if (!current
|| current.complete
|| current.retryIndex !== undefined
|| current.scanId !== record.scanId
|| current.inventoryKey !== record.inventoryKey
|| current.scope !== record.scope
|| current.segmentCount !== record.index
|| !cursorFingerprintsEqual(current.nextCursorFingerprints, record.inputCursorFingerprints)
|| current.segmentCount >= ASSET_SYNC_INVENTORY_MAX_SEGMENTS) {
return false;
}
const newUniqueItems = record.items.reduce((count, item) => count + (current.outcomes.has(item.remoteAssetId) ? 0 : 1), 0);
return current.outcomes.size + current.anonymousBlocked + newUniqueItems + record.anonymousBlocked
<= ASSET_SYNC_INVENTORY_MAX_UNIQUE_ITEMS;
}
function snapshotFromFirstSegment(record) {
if (record.items.length + record.anonymousBlocked > ASSET_SYNC_INVENTORY_MAX_UNIQUE_ITEMS)
return undefined;
if (record.cursorHeld === true && !inventoryBatchHasRetryableOutcome([record]))
return undefined;
const retryIndex = inventoryBatchRetryIndex(record);
return immutableInventorySnapshot({
scanId: record.scanId,
inventoryKey: record.inventoryKey,
scope: record.scope,
segmentCount: 1,
nextCursorFingerprints: record.nextCursorFingerprints,
outcomes: new Map(record.items.map((item) => [item.remoteAssetId, item.outcome])),
anonymousBlocked: record.anonymousBlocked,
complete: retryIndex === undefined && inventoryScanComplete(record.scope, record.nextCursorFingerprints),
...(retryIndex === undefined ? {} : { retryIndex }),
});
}
function appendSegmentToSnapshot(current, record) {
const outcomes = new Map(current.outcomes);
for (const item of record.items) {
if (!outcomes.has(item.remoteAssetId))
outcomes.set(item.remoteAssetId, item.outcome);
}
const retryIndex = inventoryBatchRetryIndex(record);
return immutableInventorySnapshot({
...current,
segmentCount: current.segmentCount + 1,
nextCursorFingerprints: record.nextCursorFingerprints,
outcomes,
anonymousBlocked: current.anonymousBlocked + record.anonymousBlocked,
complete: retryIndex === undefined && inventoryScanComplete(record.scope, record.nextCursorFingerprints),
retryIndex,
});
}
function inventoryBatchRetryIndex(first) {
return first.cursorHeld === true ? first.index : undefined;
}
function inventoryBatchHasRetryableOutcome(records) {
return records.some((record) => record.items.some((item) => (item.outcome === 'failed' || item.outcome === 'pending')));
}
function findInventoryRetryTail(raw, snapshot) {
if (snapshot.retryIndex === undefined)
return undefined;
const lines = raw.split('\n');
let found;
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const first = parseInventoryLine(lines[lineIndex]);
if (!first
|| first.inventoryKey !== snapshot.inventoryKey
|| first.scanId !== snapshot.scanId
|| first.index !== snapshot.retryIndex
|| first.batchIndex !== undefined && first.batchIndex !== 0)
continue;
const records = [first];
const lineIndexes = new Set([lineIndex]);
if (first.batchSize !== undefined) {
for (let offset = 1; offset < first.batchSize; offset += 1) {
const next = parseInventoryLine(lines[lineIndex + offset]);
if (!next || !continuesPendingBatch(records, next))
break;
records.push(next);
lineIndexes.add(lineIndex + offset);
}
if (records.length !== first.batchSize)
continue;
}
if (records.length !== snapshot.segmentCount - snapshot.retryIndex
|| inventoryBatchRetryIndex(first) !== snapshot.retryIndex)
continue;
found = { records: Object.freeze(records), lineIndexes };
}
return found;
}
function parseInventoryLine(line) {
if (!line?.trim())
return null;
try {
return parseAssetSyncInventorySegmentRecord(JSON.parse(line));
}
catch {
return null;
}
}
function mergeInventoryRetryItems(previousRecords, retriedItems) {
const retriedById = new Map();
for (const item of retriedItems) {
if (retriedById.has(item.remoteAssetId))
return null;
retriedById.set(item.remoteAssetId, item);
}
const previousIds = new Set();
const merged = [];
for (const record of previousRecords) {
for (const item of record.items) {
previousIds.add(item.remoteAssetId);
const retried = retriedById.get(item.remoteAssetId);
merged.push(retried ? {
remoteAssetId: item.remoteAssetId,
outcome: mergeInventoryRetryOutcome(item.outcome, retried.outcome),
} : item);
}
}
for (const item of retriedItems) {
if (!previousIds.has(item.remoteAssetId))
merged.push(item);
}
return merged;
}
function mergeInventoryRetryOutcome(previous, retried) {
if (previous !== 'failed' && previous !== 'pending')
return previous;
if (retried === 'imported' || retried === 'already_local' || retried === 'blocked')
return retried;
if (previous === 'failed' && retried === 'pending')
return previous;
return retried;
}
function removeInventoryRetryTail(raw, removedLineIndexes) {
return raw.split('\n')
.filter((line, index) => line.trim() && !removedLineIndexes.has(index))
.map((line) => `${line}\n`)
.join('');
}
function immutableInventorySnapshot(snapshot) {
return Object.freeze({
...snapshot,
nextCursorFingerprints: Object.freeze({ ...snapshot.nextCursorFingerprints }),
outcomes: new Map(snapshot.outcomes),
});
}
function inventoryScanComplete(scope, cursors) {
if (scope === 'purchased')
return cursors.purchased === null;
if (scope === 'published')
return cursors.published === null;
return cursors.purchased === null && cursors.published === null;
}
function cursorFingerprintsEqual(left, right) {
return left.purchased === right.purchased && left.published === right.published;
}
function removeInventoryScan(raw, inventoryKey) {
return raw.split('\n').filter((line) => {
if (!line.trim())
return false;
try {
const record = JSON.parse(line);
return record['recordType'] !== 'inventory_scan' || record['inventoryKey'] !== inventoryKey;
}
catch {
return true;
}
}).map((line) => `${line}\n`).join('');
}
function inventoryScanBytes(raw) {
let bytes = 0;
for (const line of raw.split('\n')) {
if (!line.trim())
continue;
try {
const record = JSON.parse(line);
if (record['recordType'] === 'inventory_scan')
bytes += Buffer.byteLength(`${line}\n`, 'utf8');
}
catch {
// Unknown corrupt rows are preserved but cannot grow through this API.
}
}
return bytes;
}
+2 -1
View File
@@ -1,4 +1,4 @@
import { type AssetKind, type AssetRecord, type AssetStoreProvider, type PutResult, type SearchQuery } from './provider.js';
import { type AssetKind, type AssetRecord, type AssetStoreProvider, type ConditionalPutOptions, type ConditionalPutResult, type PutResult, type SearchQuery } from './provider.js';
/**
* jsonl (M3-2, v1 src/gep/assetStore.js ).
* kind (genes/capsules/events.jsonl); append-only; O_EXCL ;
@@ -19,6 +19,7 @@ export declare class LocalJsonlProvider implements AssetStoreProvider {
private ensureFresh;
private updateFileStateAfterWrite;
put(asset: AssetRecord): Promise<PutResult>;
putConditional(asset: AssetRecord, options?: ConditionalPutOptions): Promise<ConditionalPutResult>;
/**
* (M8-2): ** asset_id** , normalizeForPut /.
* v1v2 ( A6 ); put(). record asset_id.
+39 -3
View File
@@ -97,15 +97,42 @@ export class LocalJsonlProvider {
this.loaded = true;
}
async put(asset) {
return this.putConditional(asset, { allowLogicalCollision: true });
}
async putConditional(asset, options) {
const { record, verified } = normalizeForPut(asset);
const file = join(this.baseDir, LOCAL_ASSET_FILES[record.type]);
const logicalId = typeof record.id === 'string' ? record.id : undefined;
let collision;
assertOptionalRegularFile(this.lockPath, 'lock_file');
acquireLock(this.lockPath);
try {
// Refresh under the shared lock so another process cannot append between reload and dedupe.
this.refreshUnderLock();
if (this.index.has(record.asset_id))
return { asset_id: record.asset_id, stored: false, verified };
if (this.index.has(record.asset_id)) {
return {
asset_id: record.asset_id,
stored: false,
verified,
status: 'already_exists',
logicalId,
};
}
collision = logicalId === undefined
? undefined
: [...this.index.values()].find((existing) => (existing.type === record.type
&& existing.id === logicalId
&& existing.asset_id !== record.asset_id));
if (collision && !options?.allowLogicalCollision) {
return {
asset_id: record.asset_id,
stored: false,
verified,
status: 'logical_collision',
logicalId,
collisionWithAssetId: collision.asset_id,
};
}
appendUtf8Durable(file, `${JSON.stringify(record)}\n`);
this.index.set(record.asset_id, record);
this.updateFileStateAfterWrite();
@@ -113,7 +140,16 @@ export class LocalJsonlProvider {
finally {
releaseLock(this.lockPath);
}
return { asset_id: record.asset_id, stored: true, verified };
return {
asset_id: record.asset_id,
stored: true,
verified,
status: 'stored',
...(collision ? {
logicalId,
collisionWithAssetId: collision.asset_id,
} : {}),
};
}
/**
* 迁移专用(M8-2): **冻结 asset_id** 原样写入, 不经 normalizeForPut 重算/校验.
+7 -2
View File
@@ -1,4 +1,4 @@
import { type AssetStoreProvider, type AssetRecord, type PutResult } from './provider.js';
import { type AssetStoreProvider, type AssetRecord, type ConditionalPutOptions, type ConditionalPutResult, type PutResult } from './provider.js';
export type ProvenanceSource = 'local' | 'migrated' | 'hub';
export type ProvenanceDecision = 'promoted' | 'revoked';
export interface ProvenanceRecord {
@@ -54,4 +54,9 @@ export declare class ProvenanceStore {
* remote-supplied asset_id is never trusted) and mark it untrusted in the sidecar. This is the ONLY path that
* should bring hub-fetched assets into the local pool trust-first from the first byte (#30.1).
*/
export declare function ingestUntrusted(store: AssetStoreProvider, prov: ProvenanceStore, record: AssetRecord, source?: ProvenanceSource): Promise<PutResult>;
export declare function ingestUntrusted(store: AssetStoreProvider, prov: ProvenanceStore, record: AssetRecord, source?: ProvenanceSource): Promise<PutResult>;
/**
* Conditional variant used by Hub sync to reject a logical-id collision without ever allowing a Hub record
* to become implicitly trusted. Providers that cannot make the condition atomically are rejected here.
*/
export declare function ingestUntrustedConditional(store: AssetStoreProvider, prov: ProvenanceStore, record: AssetRecord, options?: ConditionalPutOptions, source?: ProvenanceSource): Promise<ConditionalPutResult>;
+16 -1
View File
@@ -4,7 +4,7 @@
// must not enter the content hash (#30.2), or it would break content-addressing. Trust-first by construction:
// selection defaults to trusted-only; an untrusted asset is promoted to trusted only by an explicit, logged act.
import { join, dirname } from 'node:path';
import { normalizeForPut } from './provider.js';
import { normalizeForPut, supportsAtomicConditionalPut, validateConditionalPutResult, } from './provider.js';
import { appendUtf8Durable, assertAssetStoreDirectory, ensureAssetStoreDirectory, readUtf8Regular, regularFileFingerprint, truncateUtf8SuffixDurable, withAssetStoreLock, } from './assetStoreStorage.js';
import { assertTrustSidecarHealthy, parseProvenanceRecord, parseSidecarJsonl, } from './assetSidecarRecords.js';
function immutableRecord(record) {
@@ -142,4 +142,19 @@ export async function ingestUntrusted(store, prov, record, source = 'hub') {
if (!result.stored)
prov.rollbackLast(mark);
return result;
}
/**
* Conditional variant used by Hub sync to reject a logical-id collision without ever allowing a Hub record
* to become implicitly trusted. Providers that cannot make the condition atomically are rejected here.
*/
export async function ingestUntrustedConditional(store, prov, record, options, source = 'hub') {
if (!supportsAtomicConditionalPut(store)) {
throw new Error('asset store does not support conditional writes');
}
const normalized = normalizeForPut(record);
const mark = prov.mark({ assetId: normalized.record.asset_id, source, trusted: false });
const result = validateConditionalPutResult(await store.putConditional(record, options), normalized.record.asset_id, options);
if (!result.stored)
prov.rollbackLast(mark);
return result;
}
+24
View File
@@ -10,6 +10,24 @@ export interface PutResult {
stored: boolean;
verified: boolean;
}
export type ConditionalPutStatus = 'stored' | 'already_exists' | 'logical_collision';
export interface ConditionalPutOptions {
/** Only explicit force-like callers may keep multiple content versions for the same type + logical id. */
allowLogicalCollision?: boolean;
}
export interface ConditionalPutResult extends PutResult {
status: ConditionalPutStatus;
logicalId?: string;
collisionWithAssetId?: string;
}
export type InvalidConditionalPutResultReason = 'malformed_result' | 'asset_id_mismatch' | 'inconsistent_status' | 'invalid_collision' | 'collision_bypass';
export declare class InvalidConditionalPutResultError extends Error {
readonly reason: InvalidConditionalPutResultReason;
readonly code = "INVALID_CONDITIONAL_PUT_RESULT";
constructor(reason: InvalidConditionalPutResultReason);
}
/** Validate an injected provider response before callers treat it as an explicit write/no-write decision. */
export declare function validateConditionalPutResult(value: unknown, expectedAssetId: string, options?: ConditionalPutOptions): ConditionalPutResult;
export interface SearchQuery {
kind?: AssetKind;
signalsAny?: string[];
@@ -24,12 +42,18 @@ export interface SearchQuery {
*/
export interface AssetStoreProvider {
put(asset: AssetRecord): Promise<PutResult>;
/** Optional atomic capability; use supportsAtomicConditionalPut() before calling through this interface. */
putConditional?(asset: AssetRecord, options?: ConditionalPutOptions): Promise<ConditionalPutResult>;
get(assetId: string): Promise<AssetRecord | null>;
/** Optional direct lookup for non-content-addressed logical ids. Callers must handle 0, 1, or multiple matches. */
findByLogicalId?(id: string, limit?: number): Promise<AssetRecord[]>;
search(query: SearchQuery): Promise<AssetRecord[]>;
list(kind?: AssetKind, limit?: number): Promise<AssetRecord[]>;
}
export type AtomicConditionalPutProvider = AssetStoreProvider & {
putConditional(asset: AssetRecord, options?: ConditionalPutOptions): Promise<ConditionalPutResult>;
};
export declare function supportsAtomicConditionalPut(provider: AssetStoreProvider): provider is AtomicConditionalPutProvider;
export declare class AssetIdMismatchError extends Error {
readonly claimed: string;
readonly actual: string;
+50
View File
@@ -1,4 +1,54 @@
import { computeAssetId, verifyAssetId } from '../wire/index.js';
export class InvalidConditionalPutResultError extends Error {
reason;
code = 'INVALID_CONDITIONAL_PUT_RESULT';
constructor(reason) {
super(`invalid conditional put result: ${reason}`);
this.reason = reason;
this.name = 'InvalidConditionalPutResultError';
}
}
/** Validate an injected provider response before callers treat it as an explicit write/no-write decision. */
export function validateConditionalPutResult(value, expectedAssetId, options) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidConditionalPutResultError('malformed_result');
}
const result = value;
const status = result['status'];
const collisionWithAssetId = result['collisionWithAssetId'];
if (typeof result['asset_id'] !== 'string'
|| typeof result['stored'] !== 'boolean'
|| typeof result['verified'] !== 'boolean'
|| (status !== 'stored' && status !== 'already_exists' && status !== 'logical_collision')
|| (result['logicalId'] !== undefined
&& (typeof result['logicalId'] !== 'string'
|| !result['logicalId'].trim()
|| result['logicalId'] !== result['logicalId'].trim()))
|| (collisionWithAssetId !== undefined
&& (typeof collisionWithAssetId !== 'string'
|| !collisionWithAssetId.trim()
|| collisionWithAssetId !== collisionWithAssetId.trim()))) {
throw new InvalidConditionalPutResultError('malformed_result');
}
if (result['asset_id'] !== expectedAssetId) {
throw new InvalidConditionalPutResultError('asset_id_mismatch');
}
if (result['stored'] !== (status === 'stored')) {
throw new InvalidConditionalPutResultError('inconsistent_status');
}
const allowLogicalCollision = options?.allowLogicalCollision === true;
if (status === 'logical_collision'
&& (!collisionWithAssetId || collisionWithAssetId === expectedAssetId || allowLogicalCollision)) {
throw new InvalidConditionalPutResultError('invalid_collision');
}
if (status === 'stored' && collisionWithAssetId && !allowLogicalCollision) {
throw new InvalidConditionalPutResultError('collision_bypass');
}
return value;
}
export function supportsAtomicConditionalPut(provider) {
return typeof provider.putConditional === 'function';
}
export class AssetIdMismatchError extends Error {
claimed;
actual;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@evomap/evolver-core",
"version": "2.0.0-beta.9",
"version": "2.0.0-beta.10",
"private": false,
"type": "module",
"description": "hub-无关核心: 算法引擎/原材料/mailbox/资产库/workflow",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@evomap/evolver-mcp",
"version": "2.0.0-beta.9",
"version": "2.0.0-beta.10",
"private": false,
"type": "module",
"description": "Evolver MCP server (agent 工具发现入口)",
@@ -20,7 +20,7 @@
}
},
"dependencies": {
"@evomap/evolver-core": "2.0.0-beta.9",
"@evomap/evolver-core": "2.0.0-beta.10",
"smol-toml": "^1.6.1"
},
"repository": {
+7 -2
View File
@@ -801,14 +801,19 @@ function isDirectRun(metaUrl, argv1) {
}
export async function connectHubRuntime(deps) {
if (deps.mode === 'private') {
const { hub } = await connectPrivateProxyHub({
const runtime = await connectPrivateProxyHub({
hubUrl: deps.hubUrl,
senderId: deps.senderId,
env: deps.env ?? process.env,
...(deps.now ? { now: deps.now } : {}),
...(deps.privateImporter ? { importer: deps.privateImporter } : {}),
});
return { hub, hello: (opts) => hub.hello(opts), heartbeat: (opts) => hub.heartbeat(opts), helloMode: 'enterprise_token' };
return {
hub: runtime.hub,
hello: runtime.hello,
heartbeat: (opts) => runtime.hub.heartbeat(opts),
helloMode: 'enterprise_token',
};
}
const selection = resolvePublicNodeSecret(deps);
const { nodeSecret, nodeSecretVersion } = selection;
+2 -1
View File
@@ -5,4 +5,5 @@ export * from './daemon/proxyDaemon.js';
export * from './lifecycle/deployGuard.js';
export * from './router/index.js';
export * from './llm/index.js';
export * from './selfUpdate/index.js';
export * from './selfUpdate/index.js';
export * from './private/adapterLoader.js';
+2 -1
View File
@@ -5,4 +5,5 @@ export * from './daemon/proxyDaemon.js';
export * from './lifecycle/deployGuard.js';
export * from './router/index.js';
export * from './llm/index.js';
export * from './selfUpdate/index.js';
export * from './selfUpdate/index.js';
export * from './private/adapterLoader.js';
@@ -0,0 +1,28 @@
import type { hub } from '@evomap/evolver-core';
import { type AccountAssetListOptions, type AccountAssetListResult } from '@evomap/evolver-adapter-public';
export interface PrivateAccountAssetHub {
listAccountAssets(opts: AccountAssetListOptions): Promise<AccountAssetListResult>;
}
interface PrivateCompatibilityResponse {
status: number;
json(): Promise<unknown>;
}
export type PrivateCompatibilityFetch = (url: string, init: {
method: string;
headers: Record<string, string>;
body?: string;
}) => Promise<PrivateCompatibilityResponse>;
interface PrivateAccountAssetCompatibilityOptions {
baseUrl: string;
auth: hub.AuthProvider;
senderId: () => string | undefined;
env: Record<string, string | undefined>;
fetchFn?: PrivateCompatibilityFetch;
}
/**
* Older official private adapters predate account inventory listing. Keep the
* compatibility wire at the private composition edge, and never replace a
* future adapter's native implementation.
*/
export declare function withPrivateAccountAssetCompatibility<T extends object>(hubCapability: T, opts: PrivateAccountAssetCompatibilityOptions): T & PrivateAccountAssetHub;
export {};
@@ -0,0 +1,196 @@
import { AuthError, HubClientError, HubUnreachableError, } from '@evomap/evolver-adapter-public';
const PRIVATE_PUBLISHED_ASSETS_PATH = '/a2a/assets/published-by-me';
const PRIVATE_PUBLISHED_MAX_PAGE_SIZE = 500;
const PRIVATE_CURSOR_MAX_LENGTH = 4096;
/**
* Older official private adapters predate account inventory listing. Keep the
* compatibility wire at the private composition edge, and never replace a
* future adapter's native implementation.
*/
export function withPrivateAccountAssetCompatibility(hubCapability, opts) {
const candidate = hubCapability;
if (typeof candidate['listAccountAssets'] === 'function') {
return hubCapability;
}
if (candidate['listAccountAssets'] !== undefined) {
throw new Error('private Hub adapter exposes an invalid account asset sync capability');
}
const client = new PrivateAccountAssetCompatibility(opts);
try {
Object.defineProperty(hubCapability, 'listAccountAssets', {
configurable: false,
enumerable: false,
writable: false,
value: (listOpts) => client.list(listOpts),
});
}
catch {
throw new Error('private Hub adapter cannot be extended with account asset sync compatibility');
}
return hubCapability;
}
class PrivateAccountAssetCompatibility {
opts;
baseUrl;
fetchFn;
constructor(opts) {
this.opts = opts;
this.baseUrl = normalizePrivateHubBaseUrl(opts.baseUrl, opts.env);
this.fetchFn = opts.fetchFn ?? globalPrivateCompatibilityFetch;
}
async list(opts) {
assertAccountAssetListOptions(opts);
if (opts.scope === 'purchased') {
// Private Hub has no marketplace/purchase ledger. An empty inventory is
// distinct from published assets and avoids importing arbitrary recall hits.
if (opts.cursor)
throw new HubClientError(400, { code: 'private_marketplace_cursor_unsupported' });
return { assets: [], count: 0, hasMore: false };
}
const limit = Math.min(opts.limit ?? 100, PRIVATE_PUBLISHED_MAX_PAGE_SIZE);
const query = new URLSearchParams({ limit: String(limit) });
const senderId = this.opts.senderId()?.trim();
if (senderId)
query.set('sender_id', senderId);
if (opts.cursor)
query.set('cursor', opts.cursor);
if (opts.type)
query.set('type', opts.type);
if (opts.status && opts.status !== 'all')
query.set('status', opts.status);
const signed = await this.opts.auth.authenticate({ method: 'GET', path: PRIVATE_PUBLISHED_ASSETS_PATH });
const headers = accountAssetHeaders(signed);
const url = `${this.baseUrl}${PRIVATE_PUBLISHED_ASSETS_PATH}?${query.toString()}`;
assertPrivateCompatibilityUrlSecure(url, this.opts.env);
let response;
try {
response = await this.fetchFn(url, { method: 'GET', headers });
}
catch (error) {
if (error instanceof AuthError || error instanceof HubClientError || error instanceof HubUnreachableError)
throw error;
throw new HubUnreachableError('Private Hub account asset request failed before a response arrived', {
context: `GET ${PRIVATE_PUBLISHED_ASSETS_PATH}`,
});
}
const body = await parseCompatibilityResponse(response);
if (response.status === 401 || response.status === 403)
throw new AuthError(response.status, body);
if (response.status >= 400 && response.status < 500)
throw new HubClientError(response.status, body);
if (response.status >= 500)
throw new Error(`private hub ${response.status} ${PRIVATE_PUBLISHED_ASSETS_PATH}`);
return parsePublishedPage(body, limit);
}
}
function assertAccountAssetListOptions(opts) {
if (!opts || (opts.scope !== 'purchased' && opts.scope !== 'published')) {
throw new HubClientError(400, { code: 'invalid_account_asset_scope' });
}
if (opts.limit !== undefined && (!Number.isSafeInteger(opts.limit) || opts.limit <= 0)) {
throw new HubClientError(400, { code: 'invalid_account_asset_limit' });
}
if (opts.cursor !== undefined && (!opts.cursor.trim() || opts.cursor.length > PRIVATE_CURSOR_MAX_LENGTH)) {
throw new HubClientError(400, { code: 'invalid_account_asset_cursor' });
}
if (opts.type !== undefined && opts.type !== 'Gene' && opts.type !== 'Capsule') {
throw new HubClientError(400, { code: 'invalid_account_asset_type' });
}
if (opts.status !== undefined && opts.status !== 'draft' && opts.status !== 'promoted' && opts.status !== 'all') {
throw new HubClientError(400, { code: 'invalid_account_asset_status' });
}
}
function accountAssetHeaders(signed) {
const headers = { accept: 'application/json', ...signed.headers };
const hasAuthorization = Object.keys(headers).some((key) => key.toLowerCase() === 'authorization');
const bodyNodeSecret = signed.bodyFields?.['node_secret'];
if (!hasAuthorization && typeof bodyNodeSecret === 'string' && bodyNodeSecret) {
headers['authorization'] = `Bearer ${bodyNodeSecret}`;
}
return headers;
}
async function parseCompatibilityResponse(response) {
if (!Number.isInteger(response.status) || response.status < 100 || response.status > 599) {
throw new HubUnreachableError('Private Hub account asset response has an invalid HTTP status', {
context: `GET ${PRIVATE_PUBLISHED_ASSETS_PATH}`,
});
}
try {
const parsed = await response.json();
const record = asRecord(parsed);
if (record)
return record;
}
catch {
// The normalized error below intentionally excludes response data.
}
throw new HubUnreachableError('Private Hub account asset response is not a JSON object', {
status: response.status,
context: `GET ${PRIVATE_PUBLISHED_ASSETS_PATH}`,
});
}
function parsePublishedPage(body, limit) {
const payload = asRecord(body['payload']) ?? body;
const assets = payload['assets'];
const hasMore = payload['has_more'] ?? payload['hasMore'];
const rawCursor = payload['next_cursor'] ?? payload['nextCursor'];
const count = payload['count'];
if (!Array.isArray(assets) || assets.length > limit || typeof hasMore !== 'boolean') {
throw malformedPublishedPage();
}
if (count !== undefined && (!Number.isSafeInteger(count) || count < 0)) {
throw malformedPublishedPage();
}
const nextCursor = rawCursor === null || rawCursor === undefined ? undefined : rawCursor;
if (nextCursor !== undefined && (typeof nextCursor !== 'string' || !nextCursor.trim() || nextCursor.length > PRIVATE_CURSOR_MAX_LENGTH)) {
throw malformedPublishedPage();
}
if (hasMore && nextCursor === undefined)
throw malformedPublishedPage();
return {
assets: assets,
...(count !== undefined ? { count: count } : {}),
hasMore,
...(nextCursor !== undefined ? { nextCursor } : {}),
};
}
function malformedPublishedPage() {
return new HubUnreachableError('Private Hub returned a malformed published asset page', {
context: `GET ${PRIVATE_PUBLISHED_ASSETS_PATH}`,
});
}
function normalizePrivateHubBaseUrl(raw, env) {
const normalized = raw.trim().replace(/\/+$/, '');
assertPrivateCompatibilityUrlSecure(normalized, env);
const parsed = new URL(normalized);
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
throw new Error('Private Hub URL must not contain credentials, query parameters, or a fragment');
}
return normalized;
}
function assertPrivateCompatibilityUrlSecure(url, env) {
let parsed;
try {
parsed = new URL(url);
}
catch {
throw new Error('Private Hub URL is invalid');
}
if (parsed.protocol === 'https:')
return;
if (parsed.protocol === 'http:' && env['EVOLVER_PRIVATE_ALLOW_INSECURE'] === '1')
return;
throw new Error('Private Hub URL must use https');
}
const globalPrivateCompatibilityFetch = async (url, init) => {
const response = await fetch(url, {
method: init.method,
headers: init.headers,
...(init.body ? { body: init.body } : {}),
redirect: 'error',
});
return { status: response.status, json: () => response.json() };
};
function asRecord(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
}
+13 -1
View File
@@ -1,5 +1,6 @@
import { hub as hubNs } from '@evomap/evolver-core';
import type { HelloResult, HeartbeatOptions, HeartbeatResult } from '../lifecycle/manager.js';
import { type PrivateAccountAssetHub, type PrivateCompatibilityFetch } from './accountAssetCompatibility.js';
export type PrivateHubWithLifecycle = hubNs.HubCapability & {
hello(opts: {
rotate: boolean;
@@ -7,6 +8,7 @@ export type PrivateHubWithLifecycle = hubNs.HubCapability & {
}): Promise<HelloResult>;
heartbeat(opts?: HeartbeatOptions): Promise<HeartbeatResult>;
};
export type PrivateProxyHub = PrivateHubWithLifecycle & PrivateAccountAssetHub;
interface PrivateSsoExchange {
identity: () => {
subject: string;
@@ -29,11 +31,19 @@ export interface ConnectPrivateHubOptions {
now?: () => number;
/** One-shot invitation token (evoinv_…) — preferred over the SSO bearer for token_required hubs. */
invitationToken?: string;
/** Ready credential from the standard Private Hub onboarding store. */
nodeSecret?: string;
fetchFn?: PrivateCompatibilityFetch;
}
type DynamicImporter = (specifier: string) => Promise<unknown>;
export interface PrivateProxyHubRuntime {
hub: PrivateHubWithLifecycle;
hub: PrivateProxyHub;
auth: hubNs.AuthProvider;
/** Enrollment-aware lifecycle entrypoint. Ready node_secret credentials must not re-run hello. */
hello(opts: {
rotate: boolean;
evolverVersion?: string;
}): Promise<HelloResult>;
}
export interface ConnectPrivateProxyHubOptions {
hubUrl: string;
@@ -41,11 +51,13 @@ export interface ConnectPrivateProxyHubOptions {
env: Record<string, string | undefined>;
now?: () => number;
importer?: DynamicImporter;
fetchFn?: PrivateCompatibilityFetch;
}
export declare function resolvePrivateEnterpriseToken(env: Record<string, string | undefined>): string | undefined;
/** One-shot invitation token (evoinv_), matching the hub's official onboarding script (A2A_INVITATION_TOKEN).
* Preferred over the enterprise token for the default token_required enrollment mode. */
export declare function resolvePrivateInvitationToken(env: Record<string, string | undefined>): string | undefined;
export declare function resolvePrivateNodeSecret(env: Record<string, string | undefined>): string | undefined;
export declare function resolvePrivateEnterpriseSubject(env: Record<string, string | undefined>): string;
export declare function connectPrivateProxyHub(opts: ConnectPrivateProxyHubOptions): Promise<PrivateProxyHubRuntime>;
export {};
+68 -5
View File
@@ -1,4 +1,5 @@
import { hub as hubNs } from '@evomap/evolver-core';
import { withPrivateAccountAssetCompatibility, } from './accountAssetCompatibility.js';
const DEFAULT_PRIVATE_ADAPTER_MODULE = '@evomap/evolver-adapter-private';
export function resolvePrivateEnterpriseToken(env) {
return firstEnv(env, 'EVOMAP_ENTERPRISE_TOKEN', 'EVOMAP_PRIVATE_HUB_TOKEN', 'PHUB_ENTERPRISE_TOKEN', 'PRIVATE_HUB_ENTERPRISE_TOKEN');
@@ -8,14 +9,21 @@ export function resolvePrivateEnterpriseToken(env) {
export function resolvePrivateInvitationToken(env) {
return firstEnv(env, 'A2A_INVITATION_TOKEN');
}
export function resolvePrivateNodeSecret(env) {
return firstEnv(env, 'EVOMAP_NODE_SECRET', 'A2A_NODE_SECRET');
}
export function resolvePrivateEnterpriseSubject(env) {
return firstEnv(env, 'EVOMAP_ENTERPRISE_SUBJECT', 'EVOMAP_PRIVATE_SUBJECT', 'PHUB_ENTERPRISE_SUBJECT', 'USER') ?? 'evolver-proxy';
}
export async function connectPrivateProxyHub(opts) {
const invitationToken = resolvePrivateInvitationToken(opts.env);
const token = resolvePrivateEnterpriseToken(opts.env);
if (!token && !invitationToken) {
throw new Error('EVOMAP_HUB_MODE=private 需要 A2A_INVITATION_TOKEN推荐对齐 PrivateHub onboarding或 EVOMAP_ENTERPRISE_TOKEN也兼容 EVOMAP_PRIVATE_HUB_TOKEN / PHUB_ENTERPRISE_TOKEN');
const nodeSecret = resolvePrivateNodeSecret(opts.env);
if (nodeSecret && !isNodeSecret(nodeSecret)) {
throw new Error('Private Hub node_secret 必须是 64 位十六进制字符串');
}
if (!token && !invitationToken && !nodeSecret) {
throw new Error('EVOMAP_HUB_MODE=private 需要 A2A_NODE_SECRET / EVOMAP_NODE_SECRET、A2A_INVITATION_TOKEN 或 EVOMAP_ENTERPRISE_TOKEN');
}
const moduleName = opts.env['EVOMAP_PRIVATE_ADAPTER_MODULE']?.trim() || DEFAULT_PRIVATE_ADAPTER_MODULE;
const connectPrivateHub = await loadConnectPrivateHub(moduleName, opts.importer ?? ((specifier) => import(specifier)));
@@ -28,16 +36,33 @@ export async function connectPrivateProxyHub(opts) {
now,
sso: {
identity: () => ({ subject }),
exchange: async () => ({ token: token ?? '' }),
exchange: async () => ({ token: token ?? nodeSecret ?? '' }),
now,
},
...(invitationToken ? { invitationToken } : {}),
...(nodeSecret ? { nodeSecret } : {}),
...(!nodeSecret && invitationToken ? { invitationToken } : {}),
...(opts.fetchFn ? { fetchFn: opts.fetchFn } : {}),
});
assertPrivateLifecycle(hub, moduleName);
if (nodeSecret)
await adoptReadyNodeSecret(auth, nodeSecret);
if (!hub.agentDirectory) {
hub.agentDirectory = hubNs.unsupportedAgentDirectoryCapability('private_hub_agent_directory_not_supported');
}
return { hub, auth };
const compatibleHub = withPrivateAccountAssetCompatibility(hub, {
baseUrl: opts.hubUrl,
auth,
senderId: opts.senderId,
env: opts.env,
...(opts.fetchFn ? { fetchFn: opts.fetchFn } : {}),
});
return {
hub: compatibleHub,
auth,
hello: nodeSecret
? async (helloOpts) => await helloWithReadyPrivateCredential(compatibleHub, auth, nodeSecret, opts.senderId, helloOpts)
: (helloOpts) => compatibleHub.hello(helloOpts),
};
}
async function loadConnectPrivateHub(moduleName, importer) {
let loaded;
@@ -60,6 +85,44 @@ function assertPrivateLifecycle(hub, moduleName) {
throw new Error(`${moduleName} 的 hub 缺少 hello/heartbeat lifecycle 方法,无法接入 evolver-proxy`);
}
}
async function adoptReadyNodeSecret(auth, nodeSecret) {
const candidate = auth;
if (typeof candidate.adoptNodeSecret === 'function') {
candidate.adoptNodeSecret.call(auth, nodeSecret);
}
if (!await authenticatesWithNodeSecret(auth, nodeSecret)) {
throw new Error('private Hub adapter cannot activate the configured node_secret');
}
}
async function helloWithReadyPrivateCredential(hub, auth, nodeSecret, senderId, opts) {
if (await authenticatesWithNodeSecret(auth, nodeSecret))
return readyPrivateHello(senderId);
return await hub.hello(opts);
}
async function authenticatesWithNodeSecret(auth, nodeSecret) {
const signed = await auth.authenticate({ method: 'GET', path: '/a2a/assets/published-by-me' });
const authorization = headerValue(signed.headers, 'authorization');
const bodySecret = signed.bodyFields?.['node_secret'];
return authorization === `Bearer ${nodeSecret}` || bodySecret === nodeSecret;
}
function headerValue(headers, name) {
const lower = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === lower)
return value;
}
return undefined;
}
function trimmedSenderId(senderId) {
return senderId()?.trim() || undefined;
}
function readyPrivateHello(senderId) {
const nodeId = trimmedSenderId(senderId);
return { ok: true, ...(nodeId ? { nodeId } : {}) };
}
function isNodeSecret(value) {
return /^[a-f0-9]{64}$/i.test(value);
}
function firstEnv(env, ...keys) {
for (const key of keys) {
const value = env[key]?.trim();
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@evomap/evolver-proxy",
"version": "2.0.0-beta.9",
"version": "2.0.0-beta.10",
"private": false,
"type": "module",
"description": "系统级 mailbox/hub 同步 daemon (Node)",
@@ -26,8 +26,8 @@
},
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.1053.0",
"@evomap/evolver-adapter-public": "2.0.0-beta.9",
"@evomap/evolver-core": "2.0.0-beta.9"
"@evomap/evolver-adapter-public": "2.0.0-beta.10",
"@evomap/evolver-core": "2.0.0-beta.10"
},
"repository": {
"type": "git",
@@ -1,6 +1,6 @@
{
"name": "@evomap/evolver-runtime-adapters",
"version": "2.0.0-beta.9",
"version": "2.0.0-beta.10",
"private": false,
"type": "module",
"description": "五 runtime 会话日志适配器 (CC/codex/cursor/kiro/opencode)",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@evomap/evolver-webui",
"version": "2.0.0-beta.9",
"version": "2.0.0-beta.10",
"private": false,
"type": "module",
"description": "保活/可视化 WebUI",
@@ -13,7 +13,7 @@
}
},
"dependencies": {
"@evomap/evolver-core": "2.0.0-beta.9"
"@evomap/evolver-core": "2.0.0-beta.10"
},
"repository": {
"type": "git",
+3 -3
View File
@@ -1,11 +1,11 @@
{
"package": "@evomap/evolver",
"version": "2.0.0-beta.9",
"version": "2.0.0-beta.10",
"channel": "v2-beta",
"publicRepo": "EvoMap/evolver",
"publicBranch": "v2-beta",
"releaseTag": "v2.0.0-beta.9",
"releaseTitle": "Evolver v2 beta v2.0.0-beta.9",
"releaseTag": "v2.0.0-beta.10",
"releaseTitle": "Evolver v2 beta v2.0.0-beta.10",
"prerelease": true,
"obfuscate": [],
"workspacePackages": [
+2 -2
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"version": "2.0.0-beta.9",
"releaseTag": "v2.0.0-beta.9",
"version": "2.0.0-beta.10",
"releaseTag": "v2.0.0-beta.10",
"channel": "v2-beta",
"publicRepo": "EvoMap/evolver",
"publicBranch": "v2-beta"