mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
Release Evolver v2 beta v2.0.0-beta.10 from 004fb3374d2c
This commit is contained in:
+8
-8
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Vendored
+25
@@ -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);
|
||||
}
|
||||
|
||||
Vendored
+12
@@ -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 {};
|
||||
Vendored
+1135
-142
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 重算/校验.
|
||||
* 仅 v1→v2 导入用(硬化 A6 存量冻结); 普通写一律走 put(). record 必须自带 asset_id.
|
||||
|
||||
+39
-3
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
Vendored
+2
-1
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user