@nowcrew/daemon 0.6.99 → 0.6.100-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cloud-report-upload.js +1 -10
- package/dist/computer-main.js +0 -0
- package/dist/console.js +17 -2
- package/dist/execution-journal.js +3 -1
- package/dist/execution-protocol.js +1 -1
- package/dist/execution-runner.js +4 -1
- package/dist/local-execution-prompt.js +8 -3
- package/dist/local-executor.js +78 -55
- package/dist/local-memory/commit-telemetry.js +30 -0
- package/dist/local-memory/execution-workspace.js +62 -0
- package/dist/local-memory/full-index-search.js +47 -0
- package/dist/local-memory/index-budget.js +9 -3
- package/dist/local-memory/initialization-identity.js +36 -0
- package/dist/local-memory/inventory-cli.js +52 -8
- package/dist/local-memory/inventory-source.js +15 -8
- package/dist/local-memory/inventory-v2.js +362 -0
- package/dist/local-memory/legacy-adapter.js +233 -0
- package/dist/local-memory/migration-access.js +242 -0
- package/dist/local-memory/migration-cli.js +54 -0
- package/dist/local-memory/migration-io.js +199 -0
- package/dist/local-memory/migration-pending.js +9 -0
- package/dist/local-memory/migration-plan.js +432 -0
- package/dist/local-memory/migration-schema.js +55 -0
- package/dist/local-memory/migration-state-invariants.js +65 -0
- package/dist/local-memory/migration-store.js +414 -0
- package/dist/local-memory/migration-tool-entry.js +21 -0
- package/dist/local-memory/pending-query.js +5 -2
- package/dist/local-memory/pending-schema.js +1 -1
- package/dist/local-memory/pending.js +16 -2
- package/dist/local-memory/prompt.js +21 -5
- package/dist/local-memory/read-cli.js +98 -76
- package/dist/local-memory/release-artifact-guard.js +55 -0
- package/dist/local-memory/rollout.js +79 -11
- package/dist/local-memory/runtime.js +191 -17
- package/dist/local-memory/source-path.js +13 -0
- package/dist/local-memory/storage-format.js +249 -0
- package/dist/local-memory/write.js +190 -21
- package/dist/local-memory/writer-mode.js +13 -0
- package/dist/local-memory-diagnostics.js +96 -15
- package/dist/local-memory-telemetry.js +20 -3
- package/dist/machine-info.js +6 -1
- package/dist/main.js +0 -0
- package/dist/marketplace-controller.js +21 -8
- package/dist/marketplace-maintenance-bridge.js +79 -0
- package/dist/marketplace-maintenance-protocol.js +40 -0
- package/dist/marketplace-protocol.js +1 -1
- package/dist/marketplace-remote.js +1 -1
- package/dist/marketplace-runner.js +1 -1
- package/dist/marketplace-serve.js +4 -2
- package/dist/memory-migration-controller.js +89 -0
- package/dist/memory-migration-inspect.js +108 -0
- package/dist/memory-migration-journal.js +14 -0
- package/dist/memory-migration-protocol.js +14 -0
- package/dist/memory-migration-runtime.js +305 -0
- package/dist/memory-prune-diagnostics.js +74 -4
- package/dist/message-file-preview.js +11 -1
- package/dist/prompt.js +9 -17
- package/dist/runner.js +1 -1
- package/dist/runtimes/claude-thread-runner.js +25 -4
- package/dist/runtimes/pi-runner.js +1 -0
- package/dist/runtimes/pi.js +54 -20
- package/dist/serve.js +39 -4
- package/dist/token.js +1 -0
- package/dist/workspace-control-handler.js +16 -3
- package/dist/workspace.js +13 -2
- package/package.json +9 -10
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const identifier = z.string().min(1).max(256).regex(/^[A-Za-z0-9_.:@-]+$/u);
|
|
3
|
+
export const MigrationDigestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
|
4
|
+
export const MigrationIdentitySchema = z.object({
|
|
5
|
+
workspaceId: identifier, agentId: identifier, migrationId: identifier,
|
|
6
|
+
sourceStorageVersion: z.union([z.literal(0), z.literal(1)]), targetStorageVersion: z.literal(2),
|
|
7
|
+
rootFingerprint: MigrationDigestSchema, ownerIdentity: identifier, machineId: identifier, executionId: identifier,
|
|
8
|
+
}).strict();
|
|
9
|
+
export const MigrationIntentReceiptSchema = MigrationIdentitySchema.extend({
|
|
10
|
+
kind: z.literal('server_write_intent'), sourceDigest: MigrationDigestSchema, planDigest: MigrationDigestSchema,
|
|
11
|
+
intentVersion: z.number().int().positive(), confirmedAt: z.string().datetime(),
|
|
12
|
+
}).strict();
|
|
13
|
+
export const MigrationProcessStoppedReceiptSchema = z.object({
|
|
14
|
+
kind: z.literal('migration_process_stopped'), migrationId: identifier, machineId: identifier,
|
|
15
|
+
executionId: identifier, ownerIdentity: identifier, pid: z.number().int().positive().nullable(),
|
|
16
|
+
stoppedAt: z.string().datetime(), proof: z.enum(['supervisor_exit', 'journal_reconciled', 'not_started']),
|
|
17
|
+
}).strict().refine(value => value.proof === 'not_started' ? value.pid === null : value.proof === 'supervisor_exit' ? value.pid !== null : true, 'migration_stop_proof_invalid');
|
|
18
|
+
export const MigrationPathSchema = z.string().max(512).refine(value => (value === 'MEMORY.md' || value === 'notes' || value === '.crew/memory/pending'
|
|
19
|
+
|| value.startsWith('notes/') || value.startsWith('.crew/memory/pending/'))
|
|
20
|
+
&& !/[\\\u0000-\u001f\u007f:]/u.test(value)
|
|
21
|
+
&& value.split('/').every(part => part !== '.' && part !== '..' && part.length > 0), 'migration_path_invalid');
|
|
22
|
+
export const MigrationRecordMappingSchema = z.object({
|
|
23
|
+
sourceFile: MigrationPathSchema.refine(value => value === 'MEMORY.md' || value.startsWith('notes/'), 'migration_pending_promotion_forbidden'), start: z.number().int().nonnegative(), end: z.number().int().positive(),
|
|
24
|
+
sourceSha256: MigrationDigestSchema.optional(),
|
|
25
|
+
scope: z.string().min(1).max(1000), category: z.enum(['preferences', 'project-rules', 'testing', 'debugging', 'git', 'incidents', 'lessons']),
|
|
26
|
+
targetId: z.string().regex(/^[A-Za-z0-9_-]+$/u).optional(), title: z.string().min(1).max(300),
|
|
27
|
+
status: z.enum(['active', 'pinned', 'candidate', 'stale', 'superseded', 'archived']).default('active'),
|
|
28
|
+
}).strict().refine(value => value.end > value.start, 'migration_mapping_range_invalid');
|
|
29
|
+
const image = z.discriminatedUnion('exists', [z.object({ exists: z.literal(false) }).strict(),
|
|
30
|
+
z.object({ exists: z.literal(true), sha256: MigrationDigestSchema, ref: z.string().regex(/^\.crew\/memory\/migrations\/[A-Za-z0-9_.:@-]+\/(?:backup|staged)\/[a-f0-9]{64}$/u), bytes: z.number().int().nonnegative() }).strict()]);
|
|
31
|
+
export const MigrationManifestSchema = z.object({
|
|
32
|
+
directories: z.array(MigrationPathSchema).max(10000),
|
|
33
|
+
files: z.array(z.object({ path: MigrationPathSchema, before: image, after: image }).strict()).max(20000),
|
|
34
|
+
recordMappings: z.array(MigrationRecordMappingSchema).max(10000),
|
|
35
|
+
}).strict();
|
|
36
|
+
export const MigrationIssueSchema = z.object({ path: z.string(), reason: z.string(), start: z.number().optional(), end: z.number().optional() }).strict();
|
|
37
|
+
export const MigrationStateSchema = z.object({
|
|
38
|
+
schemaVersion: z.literal(1), identity: MigrationIdentitySchema,
|
|
39
|
+
storageVersion: z.union([z.literal(0), z.literal(1), z.literal(2)]), targetStorageVersion: z.literal(2), origin: z.enum(['migrated', 'initialized']),
|
|
40
|
+
migrationId: identifier, phase: z.enum(['prepared', 'applying', 'committed', 'rolled_back', 'repair_required']),
|
|
41
|
+
sourceDigest: MigrationDigestSchema, planDigest: MigrationDigestSchema, manifest: MigrationManifestSchema,
|
|
42
|
+
intentReceipt: MigrationIntentReceiptSchema.optional(), issues: z.array(MigrationIssueSchema),
|
|
43
|
+
}).strict();
|
|
44
|
+
export const MigrationHistorySchema = z.object({ kind: z.enum(['none', 'existing', 'unavailable']), migrationId: identifier.optional(), writeIntentConfirmed: z.boolean().optional(), isNewAgent: z.boolean().optional(),
|
|
45
|
+
rolledBackOperations: z.array(z.object({ migrationId: identifier, sourceDigest: MigrationDigestSchema, planDigest: MigrationDigestSchema }).strict()).max(10000).optional(),
|
|
46
|
+
}).strict();
|
|
47
|
+
export class MigrationError extends Error {
|
|
48
|
+
code;
|
|
49
|
+
issues;
|
|
50
|
+
constructor(code, issues = []) {
|
|
51
|
+
super(code);
|
|
52
|
+
this.code = code;
|
|
53
|
+
this.issues = issues;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { MigrationError, MigrationIntentReceiptSchema, } from './migration-schema.js';
|
|
3
|
+
export const migrationOperationRoot = (identity) => `.crew/memory/migrations/${identity.migrationId}`;
|
|
4
|
+
function canonical(data) {
|
|
5
|
+
if (Array.isArray(data))
|
|
6
|
+
return data.map(canonical);
|
|
7
|
+
if (data !== null && typeof data === 'object') {
|
|
8
|
+
return Object.fromEntries(Object.entries(data)
|
|
9
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
10
|
+
.map(([key, value]) => [key, canonical(value)]));
|
|
11
|
+
}
|
|
12
|
+
return data;
|
|
13
|
+
}
|
|
14
|
+
export const encodeMigrationData = (data) => Buffer.from(JSON.stringify(canonical(data)));
|
|
15
|
+
export const sameMigrationData = (left, right) => encodeMigrationData(left).equals(encodeMigrationData(right));
|
|
16
|
+
export function migrationPlanDigest(identity, sourceDigest, manifest) {
|
|
17
|
+
return createHash('sha256')
|
|
18
|
+
.update(encodeMigrationData({ identity, sourceDigest, manifest }))
|
|
19
|
+
.digest('hex');
|
|
20
|
+
}
|
|
21
|
+
export function verifyMigrationIntent(receipt, state) {
|
|
22
|
+
const parsed = MigrationIntentReceiptSchema.parse(receipt);
|
|
23
|
+
for (const key of Object.keys(state.identity)) {
|
|
24
|
+
if (parsed[key] !== state.identity[key])
|
|
25
|
+
throw new MigrationError('migration_intent_identity_mismatch');
|
|
26
|
+
}
|
|
27
|
+
if (parsed.sourceDigest !== state.sourceDigest || parsed.planDigest !== state.planDigest) {
|
|
28
|
+
throw new MigrationError('migration_intent_digest_mismatch');
|
|
29
|
+
}
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
/** Pure validation shared by migration recovery and read-only inventory. */
|
|
33
|
+
export function assertMigrationStateIntegrity(state, identity) {
|
|
34
|
+
if (!sameMigrationData(state.identity, identity) || state.migrationId !== identity.migrationId
|
|
35
|
+
|| state.planDigest !== migrationPlanDigest(identity, state.sourceDigest, state.manifest)) {
|
|
36
|
+
throw new MigrationError('migration_identity_or_manifest_invalid');
|
|
37
|
+
}
|
|
38
|
+
// A prepared/applying/rolled-back journal still describes the source store;
|
|
39
|
+
// only a committed journal describes the V2 target. repair_required may
|
|
40
|
+
// represent either side because it can be recorded after a committed target
|
|
41
|
+
// is found damaged. Reject contradictory states before any recovery or
|
|
42
|
+
// pending-only escape hatch can use them.
|
|
43
|
+
const sourceVersion = state.identity.sourceStorageVersion;
|
|
44
|
+
const sourcePhase = state.phase === 'prepared' || state.phase === 'applying' || state.phase === 'rolled_back';
|
|
45
|
+
if (state.origin === 'initialized' && sourceVersion !== 0
|
|
46
|
+
|| sourcePhase && state.storageVersion !== sourceVersion
|
|
47
|
+
|| state.phase === 'committed' && state.storageVersion !== state.targetStorageVersion
|
|
48
|
+
|| state.targetStorageVersion !== identity.targetStorageVersion) {
|
|
49
|
+
throw new MigrationError('migration_identity_or_manifest_invalid');
|
|
50
|
+
}
|
|
51
|
+
const paths = state.manifest.files.map(file => file.path);
|
|
52
|
+
if (new Set(paths).size !== paths.length || !paths.includes('MEMORY.md')) {
|
|
53
|
+
throw new MigrationError('migration_manifest_invalid');
|
|
54
|
+
}
|
|
55
|
+
const operationRoot = `${migrationOperationRoot(identity)}/`;
|
|
56
|
+
for (const file of state.manifest.files) {
|
|
57
|
+
for (const image of [file.before, file.after]) {
|
|
58
|
+
if (image.exists && !image.ref.startsWith(operationRoot)) {
|
|
59
|
+
throw new MigrationError('migration_manifest_invalid');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (state.intentReceipt !== undefined)
|
|
64
|
+
verifyMigrationIntent(state.intentReceipt, state);
|
|
65
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { statfs, rm, readdir, opendir } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { atomicBytes, digest, inventory, readBytes, safePath, STATE_PATH, syncDirectory, validateMigrationRoot } from './migration-io.js';
|
|
5
|
+
import { initializeExactWorkspaceSeed, planMemoryMigration, validateMigrationTarget } from './migration-plan.js';
|
|
6
|
+
import { MigrationError, MigrationHistorySchema, MigrationProcessStoppedReceiptSchema, MigrationStateSchema } from './migration-schema.js';
|
|
7
|
+
import { inspectManagedDetailFilesWithMappings } from './inventory-v2.js';
|
|
8
|
+
import { assertMigrationStateIntegrity as assertIdentity, encodeMigrationData as encode, migrationOperationRoot as opRoot, migrationPlanDigest as planDigest, sameMigrationData as same, verifyMigrationIntent as verifyIntent, } from './migration-state-invariants.js';
|
|
9
|
+
import { detectLocalMemoryStorageVersion, parseFullMemoryIndex } from './storage-format.js';
|
|
10
|
+
import { isLocalMemoryInitializationIdentity, localMemoryInitializationIdentity } from './initialization-identity.js';
|
|
11
|
+
async function immutable(root, path, data) {
|
|
12
|
+
const previous = await readBytes(root, path, 64 * 1024 * 1024);
|
|
13
|
+
if (previous) {
|
|
14
|
+
if (digest(previous) !== digest(data))
|
|
15
|
+
throw new MigrationError('migration_immutable_hash_mismatch', [{ path, reason: 'hash_mismatch' }]);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
await atomicBytes(root, path, data);
|
|
19
|
+
}
|
|
20
|
+
async function save(root, state) {
|
|
21
|
+
const parsed = MigrationStateSchema.parse(state);
|
|
22
|
+
assertIdentity(parsed, parsed.identity);
|
|
23
|
+
if (parsed.phase === 'committed' || parsed.phase === 'rolled_back') {
|
|
24
|
+
const previous = await readBytes(root, `${opRoot(parsed.identity)}/terminal.json`, 32 * 1024 * 1024);
|
|
25
|
+
if (previous && !same(JSON.parse(previous.toString('utf8')), parsed)) {
|
|
26
|
+
throw new MigrationError('migration_terminal_conflict');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
await atomicBytes(root, STATE_PATH, encode(parsed));
|
|
30
|
+
// State is the commit point. Retain the same terminal fact separately so accidental state loss cannot replay a completed plan.
|
|
31
|
+
if (parsed.phase === 'committed' || parsed.phase === 'rolled_back')
|
|
32
|
+
await immutable(root, `${opRoot(parsed.identity)}/terminal.json`, encode(parsed));
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
async function readState(root) {
|
|
36
|
+
const bytes = await readBytes(root, STATE_PATH, 32 * 1024 * 1024);
|
|
37
|
+
if (!bytes)
|
|
38
|
+
return null;
|
|
39
|
+
let state;
|
|
40
|
+
try {
|
|
41
|
+
state = MigrationStateSchema.parse(JSON.parse(bytes.toString('utf8')));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new MigrationError('migration_state_invalid');
|
|
45
|
+
}
|
|
46
|
+
return state;
|
|
47
|
+
}
|
|
48
|
+
async function load(input) {
|
|
49
|
+
const state = await readState(input.agentDir);
|
|
50
|
+
if (state)
|
|
51
|
+
assertIdentity(state, input.identity);
|
|
52
|
+
return state;
|
|
53
|
+
}
|
|
54
|
+
export async function statusMemoryMigration(input) {
|
|
55
|
+
const identity = await validateMigrationRoot(input.agentDir, input.identity);
|
|
56
|
+
return load({ ...input, identity });
|
|
57
|
+
}
|
|
58
|
+
export async function prepareMemoryMigration(input, hooks = {}) {
|
|
59
|
+
const identity = await validateMigrationRoot(input.agentDir, input.identity);
|
|
60
|
+
const history = MigrationHistorySchema.parse(input.serverHistory);
|
|
61
|
+
const existing = await readState(input.agentDir);
|
|
62
|
+
if (existing?.migrationId === identity.migrationId) {
|
|
63
|
+
assertIdentity(existing, identity);
|
|
64
|
+
return existing;
|
|
65
|
+
}
|
|
66
|
+
if (history.kind !== 'none' || history.writeIntentConfirmed === true)
|
|
67
|
+
throw new MigrationError(history.kind === 'existing' ? 'migration_recovery_required' : 'migration_server_history_unavailable');
|
|
68
|
+
const operations = await readdir(await safePath(input.agentDir, '.crew/memory/migrations')).catch((error) => {
|
|
69
|
+
if (error.code === 'ENOENT')
|
|
70
|
+
return [];
|
|
71
|
+
throw error;
|
|
72
|
+
});
|
|
73
|
+
await verifyRolledBackPredecessors(input.agentDir, identity, history, operations, existing);
|
|
74
|
+
const leftovers = await readBytes(input.agentDir, `${opRoot(identity)}/manifest.json`, 32 * 1024 * 1024);
|
|
75
|
+
if (leftovers) {
|
|
76
|
+
const prepared = MigrationStateSchema.parse(JSON.parse(leftovers.toString('utf8')));
|
|
77
|
+
assertIdentity(prepared, identity);
|
|
78
|
+
const intent = await readBytes(input.agentDir, `${opRoot(identity)}/intent.json`);
|
|
79
|
+
const current = await classify(input.agentDir, prepared);
|
|
80
|
+
if (intent || current.unknown.length || current.classes.some(value => value !== 'B' && value !== 'AB'))
|
|
81
|
+
throw new MigrationError('migration_recovery_required');
|
|
82
|
+
if (!await copies(input.agentDir, prepared, 'before') || !await copies(input.agentDir, prepared, 'after'))
|
|
83
|
+
throw new MigrationError('migration_copy_invalid');
|
|
84
|
+
return save(input.agentDir, prepared);
|
|
85
|
+
}
|
|
86
|
+
const before = await inventory(input.agentDir);
|
|
87
|
+
const sourceMemory = before.files.get('MEMORY.md');
|
|
88
|
+
if (history.isNewAgent === true && identity.sourceStorageVersion === 0 && before.files.size === 0 && before.directories.length === 0)
|
|
89
|
+
throw new MigrationError('migration_workspace_seed_required');
|
|
90
|
+
const previousSeed = existing?.origin === 'initialized' && existing.phase === 'rolled_back'
|
|
91
|
+
? existing.manifest.files.find(file => file.path === 'MEMORY.md')?.before : undefined;
|
|
92
|
+
const retryingOriginalSeed = previousSeed?.exists === true && sourceMemory !== undefined && digest(sourceMemory) === previousSeed.sha256;
|
|
93
|
+
const expectedInitializationIdentity = await localMemoryInitializationIdentity(input.agentDir, identity.agentId, {
|
|
94
|
+
workspaceId: identity.workspaceId,
|
|
95
|
+
machineId: identity.machineId,
|
|
96
|
+
});
|
|
97
|
+
const onlyCurrentOperationResidue = isLocalMemoryInitializationIdentity(identity, expectedInitializationIdentity)
|
|
98
|
+
&& existing === null && operations.length === 1
|
|
99
|
+
&& operations[0] === identity.migrationId;
|
|
100
|
+
const pristineSeed = (history.isNewAgent === true && (operations.length === 0 || onlyCurrentOperationResidue) || retryingOriginalSeed) && identity.sourceStorageVersion === 0
|
|
101
|
+
&& before.files.size === 1 && sourceMemory !== undefined && before.directories.every(path => path === 'notes' || path === '.crew/memory/pending')
|
|
102
|
+
? initializeExactWorkspaceSeed(sourceMemory) : null;
|
|
103
|
+
const initialized = history.isNewAgent === true && identity.sourceStorageVersion === 0
|
|
104
|
+
&& pristineSeed !== null;
|
|
105
|
+
const after = pristineSeed !== null ? { files: new Map([['MEMORY.md', pristineSeed]]), recordMappings: [] } : planMemoryMigration(identity, before.files, input.recordMappings);
|
|
106
|
+
if (pristineSeed !== null)
|
|
107
|
+
validateMigrationTarget(after.files);
|
|
108
|
+
const paths = [...new Set([...before.files.keys(), ...after.files.keys()])].sort();
|
|
109
|
+
const manifest = { directories: before.directories, recordMappings: after.recordMappings, files: paths.map(path => {
|
|
110
|
+
const b = before.files.get(path);
|
|
111
|
+
const a = after.files.get(path);
|
|
112
|
+
return { path, before: b ? { exists: true, sha256: digest(b), ref: `${opRoot(identity)}/backup/${digest(b)}`, bytes: b.length } : { exists: false },
|
|
113
|
+
after: a ? { exists: true, sha256: digest(a), ref: `${opRoot(identity)}/staged/${digest(a)}`, bytes: a.length } : { exists: false } };
|
|
114
|
+
}) };
|
|
115
|
+
const sourceDigest = digest(encode({ directories: before.directories, files: manifest.files.map(file => ({ path: file.path, before: file.before.exists ? { exists: true, sha256: file.before.sha256 } : { exists: false } })) }));
|
|
116
|
+
const state = { schemaVersion: 1, identity, storageVersion: identity.sourceStorageVersion, targetStorageVersion: 2, origin: initialized ? 'initialized' : 'migrated', migrationId: identity.migrationId, phase: 'prepared', sourceDigest,
|
|
117
|
+
planDigest: planDigest(identity, sourceDigest, manifest), manifest, issues: [] };
|
|
118
|
+
MigrationStateSchema.parse(state);
|
|
119
|
+
const beforeBytes = [...before.files.values()].reduce((sum, value) => sum + value.length, 0);
|
|
120
|
+
const afterBytes = [...after.files.values()].reduce((sum, value) => sum + value.length, 0);
|
|
121
|
+
const max = Math.max(0, ...[...before.files.values(), ...after.files.values()].map(value => value.length));
|
|
122
|
+
const available = hooks.availableBytes ? await hooks.availableBytes() : await statfs(input.agentDir).then(value => value.bavail * value.bsize);
|
|
123
|
+
if (available < beforeBytes + afterBytes + max + encode(state).length * 3)
|
|
124
|
+
throw new MigrationError('migration_insufficient_disk_space');
|
|
125
|
+
for (const file of manifest.files) {
|
|
126
|
+
if (file.before.exists)
|
|
127
|
+
await immutable(input.agentDir, file.before.ref, before.files.get(file.path));
|
|
128
|
+
if (file.after.exists)
|
|
129
|
+
await immutable(input.agentDir, file.after.ref, after.files.get(file.path));
|
|
130
|
+
}
|
|
131
|
+
const current = await inventory(input.agentDir);
|
|
132
|
+
if (!same([...current.files].map(([path, bytes]) => [path, digest(bytes)]), [...before.files].map(([path, bytes]) => [path, digest(bytes)])) || !same(current.directories, before.directories))
|
|
133
|
+
throw new MigrationError('migration_source_changed');
|
|
134
|
+
await immutable(input.agentDir, `${opRoot(identity)}/manifest.json`, encode(state));
|
|
135
|
+
return save(input.agentDir, state);
|
|
136
|
+
}
|
|
137
|
+
/** 新操作只接纳服务端明确授权、摘要相符的回退终态;旧备份和历史保持不可变。 */
|
|
138
|
+
async function verifyRolledBackPredecessors(root, identity, history, operations, existing) {
|
|
139
|
+
const authorized = history.rolledBackOperations ?? [];
|
|
140
|
+
const allowed = new Map(authorized.map(operation => [operation.migrationId, operation]));
|
|
141
|
+
if (allowed.size !== authorized.length || allowed.has(identity.migrationId)
|
|
142
|
+
|| authorized.length > 0 && history.migrationId !== identity.migrationId
|
|
143
|
+
|| operations.some(name => name !== identity.migrationId && !allowed.has(name))
|
|
144
|
+
|| authorized.some(operation => !operations.includes(operation.migrationId))
|
|
145
|
+
|| existing && !allowed.has(existing.migrationId))
|
|
146
|
+
throw new MigrationError('migration_operation_residue');
|
|
147
|
+
for (const previous of authorized) {
|
|
148
|
+
const bytes = await readBytes(root, `.crew/memory/migrations/${previous.migrationId}/terminal.json`, 32 * 1024 * 1024);
|
|
149
|
+
if (!bytes)
|
|
150
|
+
throw new MigrationError('migration_rollback_history_invalid');
|
|
151
|
+
const terminal = MigrationStateSchema.parse(JSON.parse(bytes.toString('utf8')));
|
|
152
|
+
assertIdentity(terminal, terminal.identity);
|
|
153
|
+
if (terminal.phase !== 'rolled_back' || terminal.migrationId !== previous.migrationId
|
|
154
|
+
|| terminal.sourceDigest !== previous.sourceDigest || terminal.planDigest !== previous.planDigest
|
|
155
|
+
|| terminal.storageVersion !== terminal.identity.sourceStorageVersion
|
|
156
|
+
|| ['workspaceId', 'agentId', 'rootFingerprint', 'ownerIdentity', 'machineId'].some(key => terminal.identity[key] !== identity[key])
|
|
157
|
+
|| existing?.migrationId === terminal.migrationId && !same(existing, terminal))
|
|
158
|
+
throw new MigrationError('migration_rollback_history_invalid');
|
|
159
|
+
const manifest = await readBytes(root, `${opRoot(terminal.identity)}/manifest.json`, 32 * 1024 * 1024);
|
|
160
|
+
if (!manifest)
|
|
161
|
+
throw new MigrationError('migration_rollback_history_invalid');
|
|
162
|
+
const prepared = MigrationStateSchema.parse(JSON.parse(manifest.toString('utf8')));
|
|
163
|
+
assertIdentity(prepared, terminal.identity);
|
|
164
|
+
if (prepared.planDigest !== terminal.planDigest || !await copies(root, terminal, 'before'))
|
|
165
|
+
throw new MigrationError('migration_rollback_history_invalid');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function matches(bytes, image) { return image.exists ? bytes !== undefined && digest(bytes) === image.sha256 : bytes === undefined; }
|
|
169
|
+
function publicationPath(state, path, sha256) {
|
|
170
|
+
return `${opRoot(state.identity)}/publish/${digest(path)}-${sha256}.tmp`;
|
|
171
|
+
}
|
|
172
|
+
async function validatePublicationCopies(root, state) {
|
|
173
|
+
const directory = `${opRoot(state.identity)}/publish`;
|
|
174
|
+
const allowed = new Map();
|
|
175
|
+
for (const file of state.manifest.files)
|
|
176
|
+
for (const image of [file.before, file.after]) {
|
|
177
|
+
if (image.exists)
|
|
178
|
+
allowed.set(publicationPath(state, file.path, image.sha256), image.sha256);
|
|
179
|
+
}
|
|
180
|
+
const stream = await opendir(await safePath(root, directory)).catch((error) => {
|
|
181
|
+
if (error.code === 'ENOENT')
|
|
182
|
+
return null;
|
|
183
|
+
throw error;
|
|
184
|
+
});
|
|
185
|
+
if (!stream)
|
|
186
|
+
return;
|
|
187
|
+
for await (const entry of stream) {
|
|
188
|
+
const path = `${directory}/${entry.name}`;
|
|
189
|
+
const expected = allowed.get(path);
|
|
190
|
+
if (!expected || !entry.isFile())
|
|
191
|
+
throw new MigrationError('migration_publication_copy_unknown', [{ path, reason: 'unrecognized_publication_copy' }]);
|
|
192
|
+
const content = await readBytes(root, path);
|
|
193
|
+
if (!content || digest(content) !== expected)
|
|
194
|
+
throw new MigrationError('migration_publication_copy_unknown', [{ path, reason: 'publication_hash_mismatch' }]);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async function classify(root, state) {
|
|
198
|
+
await validatePublicationCopies(root, state);
|
|
199
|
+
const current = await inventory(root);
|
|
200
|
+
const known = new Set(state.manifest.files.map(file => file.path));
|
|
201
|
+
const allowedDirs = new Set(state.manifest.directories);
|
|
202
|
+
for (const file of state.manifest.files)
|
|
203
|
+
if (file.after.exists) {
|
|
204
|
+
let dir = dirname(file.path);
|
|
205
|
+
while (dir !== '.' && dir !== '.crew' && dir !== '.crew/memory') {
|
|
206
|
+
allowedDirs.add(dir);
|
|
207
|
+
dir = dirname(dir);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const unknown = [...current.files.keys()].filter(path => !known.has(path));
|
|
211
|
+
unknown.push(...current.directories.filter(path => !allowedDirs.has(path)), ...state.manifest.directories.filter(path => !current.directories.includes(path)));
|
|
212
|
+
return { files: current.files, unknown, classes: state.manifest.files.map(file => { const b = matches(current.files.get(file.path), file.before), a = matches(current.files.get(file.path), file.after); return a && b ? 'AB' : a ? 'A' : b ? 'B' : 'X'; }) };
|
|
213
|
+
}
|
|
214
|
+
async function copies(root, state, side) {
|
|
215
|
+
const files = new Map();
|
|
216
|
+
try {
|
|
217
|
+
for (const file of state.manifest.files) {
|
|
218
|
+
const image = file[side];
|
|
219
|
+
if (image.exists) {
|
|
220
|
+
const bytes = await readBytes(root, image.ref);
|
|
221
|
+
if (!bytes || bytes.length !== image.bytes || digest(bytes) !== image.sha256)
|
|
222
|
+
return null;
|
|
223
|
+
files.set(file.path, bytes);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
return files;
|
|
231
|
+
}
|
|
232
|
+
async function repair(root, state, paths, reason) { return save(root, { ...state, phase: 'repair_required', issues: paths.map(path => ({ path, reason })) }); }
|
|
233
|
+
async function publish(root, state, side, hooks) {
|
|
234
|
+
let index = 0;
|
|
235
|
+
for (const file of state.manifest.files) {
|
|
236
|
+
const current = await readBytes(root, file.path);
|
|
237
|
+
const bytes = current ?? undefined;
|
|
238
|
+
if (matches(bytes, file[side]))
|
|
239
|
+
continue;
|
|
240
|
+
const opposite = file[side === 'after' ? 'before' : 'after'];
|
|
241
|
+
if (!matches(bytes, opposite))
|
|
242
|
+
throw new MigrationError('migration_unknown_change', [{ path: file.path, reason: 'changed_before_replace' }]);
|
|
243
|
+
const image = file[side];
|
|
244
|
+
if (image.exists) {
|
|
245
|
+
const data = await readBytes(root, image.ref);
|
|
246
|
+
if (!data || digest(data) !== image.sha256)
|
|
247
|
+
throw new MigrationError('migration_copy_invalid');
|
|
248
|
+
const temporaryPath = publicationPath(state, file.path, image.sha256);
|
|
249
|
+
await atomicBytes(root, file.path, data, { temporaryPath, beforeRename: () => hooks.beforeReplace?.(file.path, temporaryPath, index),
|
|
250
|
+
afterWriteChunk: (writtenPath, written, total) => hooks.afterPublicationWrite?.(file.path, writtenPath, written, total) });
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
const target = await safePath(root, file.path);
|
|
254
|
+
await rm(target);
|
|
255
|
+
await syncDirectory(dirname(target));
|
|
256
|
+
}
|
|
257
|
+
await hooks.afterReplace?.(file.path, index++);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function validTarget(files, mappings = [], sourceHashes = new Map()) {
|
|
261
|
+
try {
|
|
262
|
+
validateMigrationTarget(files);
|
|
263
|
+
const memory = files.get('MEMORY.md');
|
|
264
|
+
if (memory === undefined)
|
|
265
|
+
return false;
|
|
266
|
+
const entries = [...parseFullMemoryIndex(memory.toString('utf8'))];
|
|
267
|
+
const sources = [...files]
|
|
268
|
+
.filter(([path]) => path === 'MEMORY.md' || path.startsWith('notes/'))
|
|
269
|
+
.map(([path, bytes]) => ({ path, bytes: bytes.length, sha256: digest(bytes), content: bytes.toString('utf8') }));
|
|
270
|
+
const inspection = inspectManagedDetailFilesWithMappings(entries, sources, mappings, sourceHashes);
|
|
271
|
+
return inspection.fileIssues.length === 0 && inspection.entryIssues.size === 0;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function manifestSourceHashes(manifest) {
|
|
278
|
+
return new Map(manifest.files
|
|
279
|
+
.flatMap(file => file.before.exists ? [[file.path, file.before.sha256]] : []));
|
|
280
|
+
}
|
|
281
|
+
async function finalState(input, state) {
|
|
282
|
+
const terminalPath = `${opRoot(state.identity)}/terminal.json`;
|
|
283
|
+
const bytes = await readBytes(input.agentDir, terminalPath, 32 * 1024 * 1024);
|
|
284
|
+
let terminal = null;
|
|
285
|
+
if (bytes) {
|
|
286
|
+
terminal = MigrationStateSchema.parse(JSON.parse(bytes.toString('utf8')));
|
|
287
|
+
assertIdentity(terminal, input.identity);
|
|
288
|
+
if ((terminal.phase !== 'committed' && terminal.phase !== 'rolled_back') || terminal.planDigest !== state.planDigest)
|
|
289
|
+
throw new MigrationError('migration_terminal_invalid');
|
|
290
|
+
}
|
|
291
|
+
else if (state.phase === 'committed' || state.phase === 'rolled_back') {
|
|
292
|
+
// Persist the already-published commit point before any content-health update can replace the mutable state.
|
|
293
|
+
await immutable(input.agentDir, terminalPath, encode(state));
|
|
294
|
+
terminal = state;
|
|
295
|
+
}
|
|
296
|
+
if (terminal?.phase === 'committed') {
|
|
297
|
+
let valid = false;
|
|
298
|
+
try {
|
|
299
|
+
valid = validTarget((await inventory(input.agentDir)).files, state.manifest.recordMappings, manifestSourceHashes(state.manifest));
|
|
300
|
+
}
|
|
301
|
+
catch { /* Protect damaged content without erasing commit history. */ }
|
|
302
|
+
if (!valid)
|
|
303
|
+
return repair(input.agentDir, terminal, ['MEMORY.md'], 'committed_target_invalid');
|
|
304
|
+
return same(state, terminal) ? terminal : save(input.agentDir, terminal);
|
|
305
|
+
}
|
|
306
|
+
if (terminal?.phase === 'rolled_back')
|
|
307
|
+
return same(state, terminal) ? terminal : save(input.agentDir, terminal);
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
export async function applyMemoryMigration(input, hooks = {}) {
|
|
311
|
+
await validateMigrationRoot(input.agentDir, input.identity);
|
|
312
|
+
let state = await load(input);
|
|
313
|
+
if (!state)
|
|
314
|
+
throw new MigrationError('migration_state_missing');
|
|
315
|
+
const done = await finalState(input, state);
|
|
316
|
+
if (done)
|
|
317
|
+
return done;
|
|
318
|
+
if (state.phase !== 'prepared')
|
|
319
|
+
throw new MigrationError('migration_recovery_required');
|
|
320
|
+
const receipt = verifyIntent(input.intentReceipt, state);
|
|
321
|
+
const classification = await classify(input.agentDir, state);
|
|
322
|
+
if (classification.unknown.length || classification.classes.some(value => value !== 'B' && value !== 'AB'))
|
|
323
|
+
return repair(input.agentDir, state, classification.unknown.length ? classification.unknown : ['MEMORY.md'], 'source_changed');
|
|
324
|
+
const backup = await copies(input.agentDir, state, 'before'), staged = await copies(input.agentDir, state, 'after');
|
|
325
|
+
if (!backup || !staged || !validTarget(staged, state.manifest.recordMappings, manifestSourceHashes(state.manifest)))
|
|
326
|
+
throw new MigrationError('migration_copy_invalid');
|
|
327
|
+
await immutable(input.agentDir, `${opRoot(state.identity)}/intent.json`, encode(receipt));
|
|
328
|
+
state = await save(input.agentDir, { ...state, phase: 'applying', intentReceipt: receipt });
|
|
329
|
+
await publish(input.agentDir, state, 'after', hooks);
|
|
330
|
+
const current = await classify(input.agentDir, state);
|
|
331
|
+
if (current.unknown.length || current.classes.some(value => value === 'X' || value === 'B') || !validTarget(current.files, state.manifest.recordMappings, manifestSourceHashes(state.manifest)))
|
|
332
|
+
return repair(input.agentDir, state, ['MEMORY.md'], 'target_validation_failed');
|
|
333
|
+
return save(input.agentDir, { ...state, phase: 'committed', storageVersion: 2 });
|
|
334
|
+
}
|
|
335
|
+
export async function recoverMemoryMigration(input, hooks = {}) {
|
|
336
|
+
await validateMigrationRoot(input.agentDir, input.identity);
|
|
337
|
+
let state = await readState(input.agentDir);
|
|
338
|
+
if (state && state.migrationId !== input.identity.migrationId) {
|
|
339
|
+
const history = MigrationHistorySchema.parse(input.serverHistory);
|
|
340
|
+
if (history.kind !== 'existing' || history.migrationId !== input.identity.migrationId)
|
|
341
|
+
throw new MigrationError('migration_history_missing_or_mismatch');
|
|
342
|
+
const operations = await readdir(await safePath(input.agentDir, '.crew/memory/migrations'));
|
|
343
|
+
await verifyRolledBackPredecessors(input.agentDir, input.identity, history, operations, state);
|
|
344
|
+
state = null;
|
|
345
|
+
}
|
|
346
|
+
if (state)
|
|
347
|
+
assertIdentity(state, input.identity);
|
|
348
|
+
if (!state) {
|
|
349
|
+
const history = MigrationHistorySchema.parse(input.serverHistory);
|
|
350
|
+
if (history.kind !== 'existing' || history.migrationId !== input.identity.migrationId)
|
|
351
|
+
throw new MigrationError('migration_history_missing_or_mismatch');
|
|
352
|
+
const terminal = await readBytes(input.agentDir, `${opRoot(input.identity)}/terminal.json`, 32 * 1024 * 1024);
|
|
353
|
+
if (terminal) {
|
|
354
|
+
const terminalState = MigrationStateSchema.parse(JSON.parse(terminal.toString('utf8')));
|
|
355
|
+
assertIdentity(terminalState, input.identity);
|
|
356
|
+
if (terminalState.phase !== 'committed' && terminalState.phase !== 'rolled_back')
|
|
357
|
+
throw new MigrationError('migration_terminal_invalid');
|
|
358
|
+
const final = await finalState(input, terminalState);
|
|
359
|
+
if (final)
|
|
360
|
+
return save(input.agentDir, final);
|
|
361
|
+
}
|
|
362
|
+
const bytes = await readBytes(input.agentDir, `${opRoot(input.identity)}/manifest.json`, 32 * 1024 * 1024);
|
|
363
|
+
if (!bytes)
|
|
364
|
+
throw new MigrationError('migration_manifest_missing_repair_required');
|
|
365
|
+
state = MigrationStateSchema.parse(JSON.parse(bytes.toString('utf8')));
|
|
366
|
+
assertIdentity(state, input.identity);
|
|
367
|
+
const receipt = await readBytes(input.agentDir, `${opRoot(input.identity)}/intent.json`);
|
|
368
|
+
if (receipt)
|
|
369
|
+
state = { ...state, phase: 'applying', intentReceipt: verifyIntent(JSON.parse(receipt.toString('utf8')), state) };
|
|
370
|
+
else if (history.writeIntentConfirmed && !input.intentReceipt)
|
|
371
|
+
throw new MigrationError('migration_intent_receipt_required');
|
|
372
|
+
}
|
|
373
|
+
const done = await finalState(input, state);
|
|
374
|
+
if (done)
|
|
375
|
+
return done;
|
|
376
|
+
const stopped = MigrationProcessStoppedReceiptSchema.parse(input.stoppedReceipt);
|
|
377
|
+
if (stopped.migrationId !== state.migrationId || stopped.machineId !== state.identity.machineId || stopped.executionId !== state.identity.executionId || stopped.ownerIdentity !== state.identity.ownerIdentity
|
|
378
|
+
|| (stopped.proof === 'not_started' && (state.phase !== 'prepared' || state.intentReceipt || input.intentReceipt)))
|
|
379
|
+
throw new MigrationError('migration_stop_proof_invalid');
|
|
380
|
+
const cancelled = z.boolean().parse(input.cancelled);
|
|
381
|
+
let receipt = state.intentReceipt;
|
|
382
|
+
if (input.intentReceipt)
|
|
383
|
+
receipt = verifyIntent(input.intentReceipt, state);
|
|
384
|
+
let current;
|
|
385
|
+
try {
|
|
386
|
+
current = await classify(input.agentDir, state);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return repair(input.agentDir, state, ['MEMORY.md'], 'unsafe_or_unreadable_source');
|
|
390
|
+
}
|
|
391
|
+
if (current.unknown.length || current.classes.includes('X'))
|
|
392
|
+
return repair(input.agentDir, state, [...current.unknown, ...state.manifest.files.filter((_, index) => current.classes[index] === 'X').map(file => file.path)], 'unknown_change');
|
|
393
|
+
const allA = current.classes.every(value => value === 'A' || value === 'AB'), allB = current.classes.every(value => value === 'B' || value === 'AB');
|
|
394
|
+
if (allA && !cancelled && validTarget(current.files, state.manifest.recordMappings, manifestSourceHashes(state.manifest)))
|
|
395
|
+
return save(input.agentDir, { ...state, phase: 'committed', storageVersion: 2, ...(receipt ? { intentReceipt: receipt } : {}) });
|
|
396
|
+
if (allB && (!receipt || cancelled))
|
|
397
|
+
return save(input.agentDir, { ...state, phase: 'rolled_back', storageVersion: state.identity.sourceStorageVersion });
|
|
398
|
+
const staged = await copies(input.agentDir, state, 'after');
|
|
399
|
+
if (receipt && !cancelled && staged && validTarget(staged, state.manifest.recordMappings, manifestSourceHashes(state.manifest))) {
|
|
400
|
+
state = await save(input.agentDir, { ...state, phase: 'applying', intentReceipt: receipt });
|
|
401
|
+
await publish(input.agentDir, state, 'after', hooks);
|
|
402
|
+
const target = await classify(input.agentDir, state);
|
|
403
|
+
if (!target.unknown.length && target.classes.every(value => value === 'A' || value === 'AB') && validTarget(target.files, state.manifest.recordMappings, manifestSourceHashes(state.manifest)))
|
|
404
|
+
return save(input.agentDir, { ...state, phase: 'committed', storageVersion: 2 });
|
|
405
|
+
}
|
|
406
|
+
const backup = await copies(input.agentDir, state, 'before');
|
|
407
|
+
if (!backup || detectLocalMemoryStorageVersion(backup.get('MEMORY.md')?.toString('utf8') ?? '') !== state.identity.sourceStorageVersion)
|
|
408
|
+
return repair(input.agentDir, state, ['MEMORY.md'], 'backup_unverifiable');
|
|
409
|
+
await publish(input.agentDir, state, 'before', hooks);
|
|
410
|
+
const restored = await classify(input.agentDir, state);
|
|
411
|
+
if (restored.unknown.length || !restored.classes.every(value => value === 'B' || value === 'AB'))
|
|
412
|
+
return repair(input.agentDir, state, ['MEMORY.md'], 'rollback_validation_failed');
|
|
413
|
+
return save(input.agentDir, { ...state, phase: 'rolled_back', storageVersion: state.identity.sourceStorageVersion });
|
|
414
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { runMemoryMigrationCommand, MigrationCommandSchema } from "./migration-cli.js";
|
|
2
|
+
import { withMemoryAccess } from "./migration-access.js";
|
|
3
|
+
async function main() {
|
|
4
|
+
const chunks = [];
|
|
5
|
+
let bytes = 0;
|
|
6
|
+
for await (const chunk of process.stdin) {
|
|
7
|
+
const part = Buffer.from(chunk);
|
|
8
|
+
bytes += part.length;
|
|
9
|
+
if (bytes > 8 * 1024 * 1024)
|
|
10
|
+
throw new Error("migration_input_limit");
|
|
11
|
+
chunks.push(part);
|
|
12
|
+
}
|
|
13
|
+
const input = MigrationCommandSchema.parse(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
14
|
+
const result = await withMemoryAccess(input.agentDir, () => runMemoryMigrationCommand(input), { maintenance: true });
|
|
15
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
16
|
+
process.exitCode = result.result === "success" || result.result === "missing" ? 0 : 1;
|
|
17
|
+
}
|
|
18
|
+
void main().catch(() => {
|
|
19
|
+
process.stdout.write(JSON.stringify({ result: "failed", issues: [{ path: "", reason: "migration_tool_failed" }] }) + "\n");
|
|
20
|
+
process.exitCode = 1;
|
|
21
|
+
});
|
|
@@ -15,6 +15,7 @@ const SearchSchema = z.object({
|
|
|
15
15
|
cursor: z.string().max(2048).optional(), limit: z.number().int().min(1).max(40).default(10),
|
|
16
16
|
}).strict();
|
|
17
17
|
const excerpt = (text, cap) => Array.from(text).slice(0, cap).join("");
|
|
18
|
+
const pendingState = (reason) => reason === "legacy_requires_migration" ? "migration_deferred" : "capacity_deferred";
|
|
18
19
|
export async function searchPendingMemoryPage(input) {
|
|
19
20
|
const parsed = SearchSchema.safeParse(input);
|
|
20
21
|
if (!parsed.success)
|
|
@@ -61,7 +62,8 @@ export async function searchPendingMemoryPage(input) {
|
|
|
61
62
|
const match = source && isRecallablePending(source.candidate) && pendingRecallScore(source.candidate, args.query) > 0
|
|
62
63
|
? { pendingId: source.candidate.pendingId, sha256: source.sha256, title: source.candidate.candidate.title,
|
|
63
64
|
summary: excerpt(source.candidate.candidate.summary, 180), triggers: source.candidate.candidate.triggers.slice(0, 4),
|
|
64
|
-
operation: "create",
|
|
65
|
+
operation: "create", reason: source.candidate.reason,
|
|
66
|
+
state: pendingState(source.candidate.reason) }
|
|
65
67
|
: null;
|
|
66
68
|
const addition = match ?? warning;
|
|
67
69
|
const size = addition ? Buffer.byteLength(JSON.stringify(addition), "utf8") + 2 : 0;
|
|
@@ -96,5 +98,6 @@ export async function readPendingMemoryBody(input) {
|
|
|
96
98
|
throw new Error("memory_pending_not_recallable");
|
|
97
99
|
const candidate = source.candidate.candidate;
|
|
98
100
|
return { pendingId: source.candidate.pendingId, sha256: source.sha256, title: candidate.title,
|
|
99
|
-
operation: "create",
|
|
101
|
+
operation: "create", reason: source.candidate.reason,
|
|
102
|
+
state: pendingState(source.candidate.reason), content: candidate.detailBody };
|
|
100
103
|
}
|
|
@@ -8,7 +8,7 @@ const ArchivedMemoryChangeSchema = LocalMemoryChangeSchema.extend({
|
|
|
8
8
|
export const PendingIdSchema = z.string().max(200).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u);
|
|
9
9
|
export const PendingMemorySchema = z.object({
|
|
10
10
|
schemaVersion: z.literal(1), pendingId: PendingIdSchema,
|
|
11
|
-
createdAt: z.string().min(1).max(100), reason: z.
|
|
11
|
+
createdAt: z.string().min(1).max(100), reason: z.enum(["index_over_budget", "legacy_requires_migration"]),
|
|
12
12
|
source: z.object({ executionId: z.string().min(1).max(500), pruneTraceId: z.string().min(1).max(500).nullable() }).strict(),
|
|
13
13
|
candidate: ArchivedMemoryChangeSchema,
|
|
14
14
|
}).strict();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { lstat, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { lstat, mkdir, realpath, rename, rm, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { isRecallablePending, parsePendingMemoryCandidate } from "./pending-schema.js";
|
|
5
5
|
import { pendingFileNames, pendingReadDirectory, PENDING_FILE_BYTES, readPendingSource } from "./pending-source.js";
|
|
@@ -103,6 +103,17 @@ export async function persistPendingMemoryCandidate(input) {
|
|
|
103
103
|
const directory = await pendingDirectory(input.agentDir);
|
|
104
104
|
const target = join(directory, `${parsed.pendingId}.json`);
|
|
105
105
|
await assertRegularFile(target);
|
|
106
|
+
try {
|
|
107
|
+
const existing = parsePendingMemoryCandidate(JSON.parse(await readFile(target, "utf8")));
|
|
108
|
+
const dedupKey = (value) => value.reason === "legacy_requires_migration"
|
|
109
|
+
? JSON.stringify({ schemaVersion: value.schemaVersion, reason: value.reason, candidate: value.candidate })
|
|
110
|
+
: JSON.stringify({ ...value, createdAt: "" });
|
|
111
|
+
if (dedupKey(existing) === dedupKey(parsed))
|
|
112
|
+
return target;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// A malformed existing candidate is replaced by the validated input below.
|
|
116
|
+
}
|
|
106
117
|
await atomicWrite(target, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
107
118
|
return target;
|
|
108
119
|
}
|
|
@@ -164,7 +175,10 @@ function truncateCodePoints(value, maxCodePoints) {
|
|
|
164
175
|
return Array.from(value).slice(0, Math.max(0, maxCodePoints)).join("");
|
|
165
176
|
}
|
|
166
177
|
export function renderPendingMemoryContext(candidates, maxCodePoints = 2_000) {
|
|
167
|
-
|
|
178
|
+
// Migration-deferred candidates remain explicitly searchable, but must not
|
|
179
|
+
// be injected into the ordinary prompt before a reviewed migration promotes
|
|
180
|
+
// them to the active catalog.
|
|
181
|
+
const recallable = candidates.filter((candidate) => isRecallablePending(candidate) && candidate.reason === "index_over_budget");
|
|
168
182
|
if (recallable.length === 0 || maxCodePoints <= 0)
|
|
169
183
|
return "";
|
|
170
184
|
const header = `\n\n## Archived memory candidates (daemon-managed)\n容量暂存的新增资料,仅为部分预览,不代表完整检索;可参考但不能覆盖现行规则,未包含内容请按需查询。\n`;
|