@phyzess/pappra 0.0.0-stage → 0.1.0-beta.2
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/LICENSE +21 -0
- package/README.md +80 -2
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/integrations/skills/pappra/SKILL.md +68 -0
- package/assets/integrations/skills/pappra/references/hosts.md +30 -0
- package/assets/integrations/skills/pappra/references/lifecycle.md +37 -0
- package/assets/integrations/skills/pappra/references/operations.md +14 -0
- package/assets/manifest.json +4 -0
- package/dist/app/collaboration.d.ts +170 -0
- package/dist/app/collaboration.js +1650 -0
- package/dist/app/doctor.d.ts +9 -0
- package/dist/app/doctor.js +1030 -0
- package/dist/app/init.d.ts +143 -0
- package/dist/app/init.js +1111 -0
- package/dist/cli/commands/code.d.ts +16 -0
- package/dist/cli/commands/code.js +127 -0
- package/dist/cli/commands/collaboration.d.ts +13 -0
- package/dist/cli/commands/collaboration.js +186 -0
- package/dist/cli/commands/continuity.d.ts +29 -0
- package/dist/cli/commands/continuity.js +133 -0
- package/dist/cli/commands/docs.d.ts +11 -0
- package/dist/cli/commands/docs.js +133 -0
- package/dist/cli/commands/doctor.d.ts +14 -0
- package/dist/cli/commands/doctor.js +57 -0
- package/dist/cli/commands/host.d.ts +13 -0
- package/dist/cli/commands/host.js +125 -0
- package/dist/cli/commands/init.d.ts +51 -0
- package/dist/cli/commands/init.js +668 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +10 -0
- package/dist/cli/output.d.ts +4 -0
- package/dist/cli/output.js +1 -0
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.js +597 -0
- package/dist/code/budget.d.ts +14 -0
- package/dist/code/budget.js +39 -0
- package/dist/code/exec.d.ts +23 -0
- package/dist/code/exec.js +133 -0
- package/dist/code/identity.d.ts +13 -0
- package/dist/code/identity.js +214 -0
- package/dist/code/index.d.ts +6 -0
- package/dist/code/index.js +3 -0
- package/dist/code/providers/codegraph.d.ts +59 -0
- package/dist/code/providers/codegraph.js +567 -0
- package/dist/code/registration.d.ts +9 -0
- package/dist/code/registration.js +293 -0
- package/dist/code/service.d.ts +53 -0
- package/dist/code/service.js +633 -0
- package/dist/code/text.d.ts +3 -0
- package/dist/code/text.js +202 -0
- package/dist/collab/admission.d.ts +450 -0
- package/dist/collab/admission.js +797 -0
- package/dist/collab/canonical.d.ts +26 -0
- package/dist/collab/canonical.js +136 -0
- package/dist/collab/contracts.d.ts +73 -0
- package/dist/collab/contracts.js +76 -0
- package/dist/collab/envelope.d.ts +52 -0
- package/dist/collab/envelope.js +241 -0
- package/dist/collab/fingerprint.d.ts +7 -0
- package/dist/collab/fingerprint.js +58 -0
- package/dist/collab/handoff.d.ts +350 -0
- package/dist/collab/handoff.js +513 -0
- package/dist/collab/index.d.ts +9 -0
- package/dist/collab/index.js +8 -0
- package/dist/collab/lifecycle.d.ts +449 -0
- package/dist/collab/lifecycle.js +534 -0
- package/dist/collab/messages.d.ts +148 -0
- package/dist/collab/messages.js +583 -0
- package/dist/collab/notify.d.ts +47 -0
- package/dist/collab/notify.js +140 -0
- package/dist/collab/preflight.d.ts +31 -0
- package/dist/collab/preflight.js +271 -0
- package/dist/collab/resources.d.ts +119 -0
- package/dist/collab/resources.js +451 -0
- package/dist/collab/store.d.ts +143 -0
- package/dist/collab/store.js +606 -0
- package/dist/collab/types.d.ts +354 -0
- package/dist/collab/types.js +1 -0
- package/dist/collab/validation.d.ts +10 -0
- package/dist/collab/validation.js +303 -0
- package/dist/continuity/context.d.ts +24 -0
- package/dist/continuity/context.js +179 -0
- package/dist/continuity/index.d.ts +3 -0
- package/dist/continuity/index.js +1 -0
- package/dist/continuity/paths.d.ts +37 -0
- package/dist/continuity/paths.js +258 -0
- package/dist/continuity/service.d.ts +59 -0
- package/dist/continuity/service.js +1258 -0
- package/dist/hosts/claude/native.d.ts +79 -0
- package/dist/hosts/claude/native.js +853 -0
- package/dist/hosts/codex/native.d.ts +345 -0
- package/dist/hosts/codex/native.js +1528 -0
- package/dist/hosts/codex/protocol.d.ts +51 -0
- package/dist/hosts/codex/protocol.js +42 -0
- package/dist/hosts/detect.d.ts +28 -0
- package/dist/hosts/detect.js +73 -0
- package/dist/hosts/index.d.ts +10 -0
- package/dist/hosts/index.js +10 -0
- package/dist/hosts/integration/bundle.d.ts +16 -0
- package/dist/hosts/integration/bundle.js +81 -0
- package/dist/hosts/integration/hook-entry.d.ts +1 -0
- package/dist/hosts/integration/hook-entry.js +68 -0
- package/dist/hosts/integration/hook.d.ts +32 -0
- package/dist/hosts/integration/hook.js +781 -0
- package/dist/hosts/integration/pi-extension-runtime.d.ts +18 -0
- package/dist/hosts/integration/pi-extension-runtime.js +89 -0
- package/dist/hosts/integration/pi-template.d.ts +3 -0
- package/dist/hosts/integration/pi-template.js +5 -0
- package/dist/hosts/integration/service.d.ts +41 -0
- package/dist/hosts/integration/service.js +1381 -0
- package/dist/hosts/integration/support.d.ts +36 -0
- package/dist/hosts/integration/support.js +41 -0
- package/dist/hosts/integration/templates.d.ts +22 -0
- package/dist/hosts/integration/templates.js +70 -0
- package/dist/hosts/pi/protocol.d.ts +22 -0
- package/dist/hosts/pi/protocol.js +28 -0
- package/dist/hosts/pi/query-result.d.ts +22 -0
- package/dist/hosts/pi/query-result.js +710 -0
- package/dist/hosts/pi/rpc.d.ts +937 -0
- package/dist/hosts/pi/rpc.js +1621 -0
- package/dist/hosts/pilot.d.ts +485 -0
- package/dist/hosts/pilot.js +593 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/infra/fs.d.ts +27 -0
- package/dist/infra/fs.js +130 -0
- package/dist/infra/git.d.ts +60 -0
- package/dist/infra/git.js +358 -0
- package/dist/infra/owned-process-group.d.ts +173 -0
- package/dist/infra/owned-process-group.js +571 -0
- package/dist/install/apply.d.ts +65 -0
- package/dist/install/apply.js +663 -0
- package/dist/install/index.d.ts +5 -0
- package/dist/install/index.js +5 -0
- package/dist/install/ownership.d.ts +85 -0
- package/dist/install/ownership.js +430 -0
- package/dist/install/plan.d.ts +39 -0
- package/dist/install/plan.js +97 -0
- package/dist/install/rollback.d.ts +18 -0
- package/dist/install/rollback.js +544 -0
- package/dist/install/runtime.d.ts +76 -0
- package/dist/install/runtime.js +1145 -0
- package/dist/knowledge/catalog.d.ts +47 -0
- package/dist/knowledge/catalog.js +308 -0
- package/dist/knowledge/context.d.ts +28 -0
- package/dist/knowledge/context.js +61 -0
- package/dist/knowledge/index.d.ts +3 -0
- package/dist/knowledge/index.js +1 -0
- package/dist/knowledge/journal.d.ts +16 -0
- package/dist/knowledge/journal.js +109 -0
- package/dist/knowledge/lifecycle.d.ts +8 -0
- package/dist/knowledge/lifecycle.js +1001 -0
- package/dist/knowledge/paths.d.ts +37 -0
- package/dist/knowledge/paths.js +236 -0
- package/dist/knowledge/refs.d.ts +36 -0
- package/dist/knowledge/refs.js +267 -0
- package/dist/knowledge/service.d.ts +21 -0
- package/dist/knowledge/service.js +393 -0
- package/dist/knowledge/types.d.ts +171 -0
- package/dist/knowledge/types.js +1 -0
- package/dist/knowledge/write.d.ts +8 -0
- package/dist/knowledge/write.js +1223 -0
- package/dist/package-info.d.ts +6 -0
- package/dist/package-info.js +19 -0
- package/dist/schemas/code.d.ts +377 -0
- package/dist/schemas/code.js +145 -0
- package/dist/schemas/collaboration.d.ts +115 -0
- package/dist/schemas/collaboration.js +106 -0
- package/dist/schemas/continuity.d.ts +315 -0
- package/dist/schemas/continuity.js +199 -0
- package/dist/schemas/diagnostics.d.ts +28 -0
- package/dist/schemas/diagnostics.js +27 -0
- package/dist/schemas/doctor.d.ts +157 -0
- package/dist/schemas/doctor.js +75 -0
- package/dist/schemas/document.d.ts +644 -0
- package/dist/schemas/document.js +296 -0
- package/dist/schemas/errors.d.ts +177 -0
- package/dist/schemas/errors.js +176 -0
- package/dist/schemas/evidence.d.ts +24 -0
- package/dist/schemas/evidence.js +16 -0
- package/dist/schemas/host.d.ts +436 -0
- package/dist/schemas/host.js +201 -0
- package/dist/schemas/index.d.ts +15 -0
- package/dist/schemas/index.js +15 -0
- package/dist/schemas/init.d.ts +121 -0
- package/dist/schemas/init.js +94 -0
- package/dist/schemas/install.d.ts +213 -0
- package/dist/schemas/install.js +139 -0
- package/dist/schemas/machine.d.ts +43 -0
- package/dist/schemas/machine.js +96 -0
- package/dist/schemas/outcome.d.ts +23 -0
- package/dist/schemas/outcome.js +17 -0
- package/dist/schemas/paths.d.ts +19 -0
- package/dist/schemas/paths.js +63 -0
- package/dist/schemas/workspace.d.ts +120 -0
- package/dist/schemas/workspace.js +201 -0
- package/dist/workspace/config-file.d.ts +36 -0
- package/dist/workspace/config-file.js +237 -0
- package/dist/workspace/config.d.ts +28 -0
- package/dist/workspace/config.js +79 -0
- package/dist/workspace/discover.d.ts +34 -0
- package/dist/workspace/discover.js +195 -0
- package/dist/workspace/documents.d.ts +21 -0
- package/dist/workspace/documents.js +395 -0
- package/dist/workspace/ignore.d.ts +10 -0
- package/dist/workspace/ignore.js +48 -0
- package/dist/workspace/index.d.ts +7 -0
- package/dist/workspace/index.js +3 -0
- package/dist/workspace/repositories.d.ts +24 -0
- package/dist/workspace/repositories.js +411 -0
- package/dist/workspace/resolve.d.ts +24 -0
- package/dist/workspace/resolve.js +268 -0
- package/dist/workspace/types.d.ts +71 -0
- package/dist/workspace/types.js +1 -0
- package/dist/workspace/validation.d.ts +18 -0
- package/dist/workspace/validation.js +197 -0
- package/package.json +105 -4
- package/skills/pappra/SKILL.md +68 -0
- package/skills/pappra/references/hosts.md +30 -0
- package/skills/pappra/references/lifecycle.md +37 -0
- package/skills/pappra/references/operations.md +14 -0
|
@@ -0,0 +1,1223 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, rename, rm, unlink } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { createDiagnostic } from '../schemas/diagnostics.js';
|
|
5
|
+
import { diagnosticCodes } from '../schemas/errors.js';
|
|
6
|
+
import { documentRecordSchema, documentWriteProposalSchema, } from '../schemas/document.js';
|
|
7
|
+
import { normalizePortableRelativePath } from '../schemas/paths.js';
|
|
8
|
+
import { failed, succeeded, } from '../schemas/outcome.js';
|
|
9
|
+
import { observePath, readFileBounded, sha256Digest } from '../infra/fs.js';
|
|
10
|
+
import { loadKnowledgeContext, sameRootIdentity } from './context.js';
|
|
11
|
+
import { catalogDigestFor, catalogScopeFor, loadKnowledgeCatalogs, locationKey, setCatalogRecord, writeCatalogFile, } from './catalog.js';
|
|
12
|
+
import { acquireKnowledgeLock, knowledgeJournalPath, releaseKnowledgeLock, writeKnowledgeJournal, } from './journal.js';
|
|
13
|
+
import { readKnowledgeFile, resolveKnowledgeLocation, } from './paths.js';
|
|
14
|
+
import { findCanonicalAlias, findRecord, findRecordByLocation, validateSources, } from './refs.js';
|
|
15
|
+
const MAX_DOCUMENT_BYTES = 8 * 1024 * 1024;
|
|
16
|
+
function diagnostic(code, message, source, hint) {
|
|
17
|
+
return createDiagnostic({ code, severity: 'error', message, source, hint });
|
|
18
|
+
}
|
|
19
|
+
function outcomeDiagnostic(failure, source) {
|
|
20
|
+
return createDiagnostic({
|
|
21
|
+
code: failure.code,
|
|
22
|
+
severity: 'error',
|
|
23
|
+
message: failure.message,
|
|
24
|
+
source,
|
|
25
|
+
hint: failure.nextAction,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function contentBuffer(content) {
|
|
29
|
+
return typeof content === 'string'
|
|
30
|
+
? Buffer.from(content, 'utf8')
|
|
31
|
+
: Buffer.from(content);
|
|
32
|
+
}
|
|
33
|
+
export function defaultVisibility(context) {
|
|
34
|
+
return context.config.knowledge.taskVisibility === 'shared'
|
|
35
|
+
? 'shareable'
|
|
36
|
+
: 'private';
|
|
37
|
+
}
|
|
38
|
+
function isWithinNewDocuments(context, path) {
|
|
39
|
+
const configured = normalizePortableRelativePath(context.config.knowledge.newDocuments);
|
|
40
|
+
if (configured === '.')
|
|
41
|
+
return true;
|
|
42
|
+
return path === configured || path.startsWith(`${configured}/`);
|
|
43
|
+
}
|
|
44
|
+
function diffPreview(oldText, nextText) {
|
|
45
|
+
const nextLines = nextText.split('\n');
|
|
46
|
+
if (oldText === null) {
|
|
47
|
+
return nextLines.map((line) => `+${line}`).join('\n');
|
|
48
|
+
}
|
|
49
|
+
const oldLines = oldText.split('\n');
|
|
50
|
+
let prefix = 0;
|
|
51
|
+
while (prefix < oldLines.length &&
|
|
52
|
+
prefix < nextLines.length &&
|
|
53
|
+
oldLines[prefix] === nextLines[prefix]) {
|
|
54
|
+
prefix += 1;
|
|
55
|
+
}
|
|
56
|
+
let suffix = 0;
|
|
57
|
+
while (suffix < oldLines.length - prefix &&
|
|
58
|
+
suffix < nextLines.length - prefix &&
|
|
59
|
+
oldLines[oldLines.length - 1 - suffix] ===
|
|
60
|
+
nextLines[nextLines.length - 1 - suffix]) {
|
|
61
|
+
suffix += 1;
|
|
62
|
+
}
|
|
63
|
+
const removed = oldLines.slice(prefix, oldLines.length - suffix);
|
|
64
|
+
const added = nextLines.slice(prefix, nextLines.length - suffix);
|
|
65
|
+
if (removed.length === 0 && added.length === 0)
|
|
66
|
+
return '';
|
|
67
|
+
return [
|
|
68
|
+
`@@ -${prefix + 1},${removed.length} +${prefix + 1},${added.length} @@`,
|
|
69
|
+
...removed.map((line) => `-${line}`),
|
|
70
|
+
...added.map((line) => `+${line}`),
|
|
71
|
+
].join('\n');
|
|
72
|
+
}
|
|
73
|
+
function decodeText(bytes) {
|
|
74
|
+
try {
|
|
75
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function sameRecordForUpdate(existing, proposed) {
|
|
82
|
+
return (existing.documentId === proposed.documentId &&
|
|
83
|
+
JSON.stringify(existing.location) === JSON.stringify(proposed.location) &&
|
|
84
|
+
existing.kind === proposed.kind &&
|
|
85
|
+
existing.lifecycle === proposed.lifecycle &&
|
|
86
|
+
existing.maturity === proposed.maturity &&
|
|
87
|
+
existing.taskId === proposed.taskId &&
|
|
88
|
+
existing.owner === proposed.owner &&
|
|
89
|
+
JSON.stringify(existing.sources) === JSON.stringify(proposed.sources) &&
|
|
90
|
+
existing.supersededBy === proposed.supersededBy &&
|
|
91
|
+
existing.lastReviewedAt === proposed.lastReviewedAt &&
|
|
92
|
+
existing.visibility === proposed.visibility);
|
|
93
|
+
}
|
|
94
|
+
function recordDiagnostics(record, source) {
|
|
95
|
+
const parsed = documentRecordSchema.safeParse(record);
|
|
96
|
+
return parsed.success
|
|
97
|
+
? []
|
|
98
|
+
: [
|
|
99
|
+
diagnostic(diagnosticCodes.document_record_invalid, `Document record is invalid: ${parsed.error.issues[0]?.message ?? 'unknown issue'}`, source, 'Fix the record metadata; no catalog bytes were written.'),
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
async function validateAndBuildRecord(context, input, location) {
|
|
103
|
+
const documentId = input.documentId ?? randomUUID();
|
|
104
|
+
const visibility = input.visibility ?? defaultVisibility(context);
|
|
105
|
+
const sources = input.sources ?? [];
|
|
106
|
+
const record = {
|
|
107
|
+
schema: 'pappra/document/1',
|
|
108
|
+
documentId,
|
|
109
|
+
location: location.location,
|
|
110
|
+
kind: input.kind,
|
|
111
|
+
lifecycle: 'active',
|
|
112
|
+
maturity: input.maturity,
|
|
113
|
+
taskId: input.taskId ?? null,
|
|
114
|
+
owner: input.owner,
|
|
115
|
+
digest: 'sha256:' + '0'.repeat(64),
|
|
116
|
+
sources: [...sources],
|
|
117
|
+
supersededBy: null,
|
|
118
|
+
lastReviewedAt: input.lastReviewedAt ?? null,
|
|
119
|
+
visibility,
|
|
120
|
+
};
|
|
121
|
+
return documentRecordSchema.safeParse(record).success
|
|
122
|
+
? succeeded(record)
|
|
123
|
+
: failed(diagnosticCodes.document_record_invalid, 'Document record could not be constructed.', { nextAction: 'Check the supplied document metadata.' });
|
|
124
|
+
}
|
|
125
|
+
export async function registerDocument(context, input) {
|
|
126
|
+
const diagnostics = [];
|
|
127
|
+
const catalogs = await loadKnowledgeCatalogs(context);
|
|
128
|
+
diagnostics.push(...catalogs.diagnostics);
|
|
129
|
+
if (!catalogs.ok) {
|
|
130
|
+
return { ok: false, status: 'conflict', record: null, diagnostics };
|
|
131
|
+
}
|
|
132
|
+
const resolved = await resolveKnowledgeLocation(context, input.location);
|
|
133
|
+
if (!resolved.ok) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
status: 'failed',
|
|
137
|
+
record: null,
|
|
138
|
+
diagnostics: [outcomeDiagnostic(resolved, context.configPath)],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const file = await readKnowledgeFile(resolved.value.absolutePath, MAX_DOCUMENT_BYTES);
|
|
142
|
+
if (!file.ok) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
status: 'failed',
|
|
146
|
+
record: null,
|
|
147
|
+
diagnostics: [outcomeDiagnostic(file, context.configPath)],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const existingByLocation = findRecordByLocation(catalogs, locationKey(input.location));
|
|
151
|
+
const existingById = input.documentId === undefined
|
|
152
|
+
? null
|
|
153
|
+
: findRecord(catalogs, input.documentId);
|
|
154
|
+
const existingForAlias = existingByLocation ?? existingById;
|
|
155
|
+
const aliasIgnore = existingForAlias !== null &&
|
|
156
|
+
existingForAlias.location.kind === input.location.kind &&
|
|
157
|
+
existingForAlias.location.path === input.location.path
|
|
158
|
+
? existingForAlias.documentId
|
|
159
|
+
: null;
|
|
160
|
+
const canonicalAlias = await findCanonicalAlias(context, catalogs, resolved.value, aliasIgnore);
|
|
161
|
+
if (canonicalAlias !== null) {
|
|
162
|
+
diagnostics.push(diagnostic(diagnosticCodes.document_location_conflict, `Canonical document path is already registered by ${canonicalAlias.documentId}.`, resolved.value.absolutePath, 'Use the existing record or a different real file; location representation does not create a second document.'));
|
|
163
|
+
return { ok: false, status: 'conflict', record: null, diagnostics };
|
|
164
|
+
}
|
|
165
|
+
if (existingByLocation !== null || existingById !== null) {
|
|
166
|
+
const existing = existingByLocation ?? existingById;
|
|
167
|
+
if (existing !== null &&
|
|
168
|
+
(input.documentId === undefined ||
|
|
169
|
+
input.documentId === existing.documentId) &&
|
|
170
|
+
existing.digest === file.value.digest &&
|
|
171
|
+
existing.location.kind === input.location.kind &&
|
|
172
|
+
existing.location.path === input.location.path) {
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
status: 'existing',
|
|
176
|
+
record: existing,
|
|
177
|
+
diagnostics,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
diagnostics.push(diagnostic(diagnosticCodes.document_already_registered, 'Document location or id is already registered with different content.', resolved.value.absolutePath, 'Read the existing record and use proposeWrite; registration never overwrites a catalog entry.'));
|
|
181
|
+
return { ok: false, status: 'conflict', record: null, diagnostics };
|
|
182
|
+
}
|
|
183
|
+
const built = await validateAndBuildRecord(context, input, resolved.value);
|
|
184
|
+
if (!built.ok) {
|
|
185
|
+
diagnostics.push(outcomeDiagnostic(built, resolved.value.absolutePath));
|
|
186
|
+
return { ok: false, status: 'failed', record: null, diagnostics };
|
|
187
|
+
}
|
|
188
|
+
const record = { ...built.value, digest: file.value.digest };
|
|
189
|
+
const invalid = recordDiagnostics(record, context.configPath);
|
|
190
|
+
diagnostics.push(...invalid);
|
|
191
|
+
if (invalid.length > 0) {
|
|
192
|
+
return { ok: false, status: 'failed', record: null, diagnostics };
|
|
193
|
+
}
|
|
194
|
+
diagnostics.push(...validateSources(context, catalogs, record.documentId, record.visibility, record.sources));
|
|
195
|
+
if (diagnostics.some((entry) => entry.severity === 'error')) {
|
|
196
|
+
return { ok: false, status: 'failed', record: null, diagnostics };
|
|
197
|
+
}
|
|
198
|
+
const lock = await acquireKnowledgeLock(context, 'write-document');
|
|
199
|
+
if (!lock.ok || lock.lock === null) {
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
status: 'failed',
|
|
203
|
+
record: null,
|
|
204
|
+
diagnostics: [
|
|
205
|
+
diagnostic(lock.status === 'locked'
|
|
206
|
+
? diagnosticCodes.document_locked
|
|
207
|
+
: diagnosticCodes.document_write_conflict, lock.message ?? 'Knowledge catalog is locked.', lock.path, 'Retry after the current knowledge operation finishes.'),
|
|
208
|
+
],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const heldLock = lock.lock;
|
|
212
|
+
const registerResult = await (async () => {
|
|
213
|
+
const reloaded = await loadKnowledgeContext(context.root);
|
|
214
|
+
if (!reloaded.ok || reloaded.context === null) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
status: 'failed',
|
|
218
|
+
record: null,
|
|
219
|
+
diagnostics: [...reloaded.diagnostics],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const fresh = await loadKnowledgeCatalogs(reloaded.context);
|
|
223
|
+
if (!fresh.ok) {
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
status: 'conflict',
|
|
227
|
+
record: null,
|
|
228
|
+
diagnostics: [...reloaded.diagnostics, ...fresh.diagnostics],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (findRecordByLocation(fresh, locationKey(record.location)) !== null ||
|
|
232
|
+
findRecord(fresh, record.documentId) !== null) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
status: 'conflict',
|
|
236
|
+
record: null,
|
|
237
|
+
diagnostics: [
|
|
238
|
+
...reloaded.diagnostics,
|
|
239
|
+
diagnostic(diagnosticCodes.document_already_registered, 'Document registration changed while the lock was acquired.', resolved.value.absolutePath, 'Read the catalog again before retrying.'),
|
|
240
|
+
],
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const freshAlias = await findCanonicalAlias(reloaded.context, fresh, resolved.value);
|
|
244
|
+
if (freshAlias !== null) {
|
|
245
|
+
return {
|
|
246
|
+
ok: false,
|
|
247
|
+
status: 'conflict',
|
|
248
|
+
record: null,
|
|
249
|
+
diagnostics: [
|
|
250
|
+
...reloaded.diagnostics,
|
|
251
|
+
diagnostic(diagnosticCodes.document_location_conflict, `Canonical document path is already registered by ${freshAlias.documentId}.`, resolved.value.absolutePath, 'Use the existing record or a different real file.'),
|
|
252
|
+
],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const currentFile = await readKnowledgeFile(resolved.value.absolutePath, MAX_DOCUMENT_BYTES);
|
|
256
|
+
if (!currentFile.ok || currentFile.value.digest !== record.digest) {
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
status: 'conflict',
|
|
260
|
+
record: null,
|
|
261
|
+
diagnostics: [
|
|
262
|
+
...reloaded.diagnostics,
|
|
263
|
+
diagnostic(diagnosticCodes.document_write_conflict, 'Document content changed before registration could be committed.', resolved.value.absolutePath, 'Register the current bytes explicitly after reviewing the change.'),
|
|
264
|
+
],
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const catalogRecord = setCatalogRecord(reloaded.context, fresh, record);
|
|
268
|
+
const written = await writeCatalogFile(reloaded.context, catalogRecord.scope, catalogRecord.catalog, catalogDigestFor(fresh, catalogRecord.scope));
|
|
269
|
+
if (!written.ok) {
|
|
270
|
+
return {
|
|
271
|
+
ok: false,
|
|
272
|
+
status: 'failed',
|
|
273
|
+
record: null,
|
|
274
|
+
diagnostics: [
|
|
275
|
+
...reloaded.diagnostics,
|
|
276
|
+
outcomeDiagnostic(written, reloaded.context.configPath),
|
|
277
|
+
],
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
ok: true,
|
|
282
|
+
status: 'registered',
|
|
283
|
+
record,
|
|
284
|
+
diagnostics: [...reloaded.diagnostics],
|
|
285
|
+
};
|
|
286
|
+
})();
|
|
287
|
+
const release = await releaseKnowledgeLock(heldLock, lock.path);
|
|
288
|
+
if (!release.ok) {
|
|
289
|
+
return {
|
|
290
|
+
...registerResult,
|
|
291
|
+
ok: false,
|
|
292
|
+
status: 'failed',
|
|
293
|
+
diagnostics: [
|
|
294
|
+
...registerResult.diagnostics,
|
|
295
|
+
outcomeDiagnostic(release, lock.path),
|
|
296
|
+
],
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
return registerResult;
|
|
300
|
+
}
|
|
301
|
+
export async function proposeWrite(context, input) {
|
|
302
|
+
const catalogs = await loadKnowledgeCatalogs(context);
|
|
303
|
+
if (!catalogs.ok) {
|
|
304
|
+
return {
|
|
305
|
+
ok: false,
|
|
306
|
+
status: 'conflict',
|
|
307
|
+
proposal: null,
|
|
308
|
+
content: null,
|
|
309
|
+
diagnostics: [...catalogs.diagnostics],
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const content = contentBuffer(input.content);
|
|
313
|
+
if (content.length > MAX_DOCUMENT_BYTES) {
|
|
314
|
+
return {
|
|
315
|
+
ok: false,
|
|
316
|
+
status: 'failed',
|
|
317
|
+
proposal: null,
|
|
318
|
+
content: null,
|
|
319
|
+
diagnostics: [
|
|
320
|
+
diagnostic(diagnosticCodes.fileTooLarge, `Document content exceeds ${MAX_DOCUMENT_BYTES} bytes.`, context.configPath, 'Reduce the document size; Pappra does not truncate knowledge.'),
|
|
321
|
+
],
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
const nextDigest = sha256Digest(content);
|
|
325
|
+
if (input.mode === 'update') {
|
|
326
|
+
if (input.expectedDigest === null || input.expectedDigest.length === 0) {
|
|
327
|
+
return {
|
|
328
|
+
ok: false,
|
|
329
|
+
status: 'conflict',
|
|
330
|
+
proposal: null,
|
|
331
|
+
content: null,
|
|
332
|
+
diagnostics: [
|
|
333
|
+
diagnostic(diagnosticCodes.document_write_conflict, 'Updating a document requires the current expectedDigest.', context.configPath, 'Read the document and pass its actual digest.'),
|
|
334
|
+
],
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
const existing = findRecord(catalogs, input.documentId);
|
|
338
|
+
if (existing === null) {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
status: 'conflict',
|
|
342
|
+
proposal: null,
|
|
343
|
+
content: null,
|
|
344
|
+
diagnostics: [
|
|
345
|
+
diagnostic(diagnosticCodes.document_not_found, `Document is not registered or is ambiguous: ${input.documentId}`, context.configPath, 'Register the document before proposing an update.'),
|
|
346
|
+
],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const resolved = await resolveKnowledgeLocation(context, existing.location, { forWrite: true });
|
|
350
|
+
if (!resolved.ok) {
|
|
351
|
+
return {
|
|
352
|
+
ok: false,
|
|
353
|
+
status: 'conflict',
|
|
354
|
+
proposal: null,
|
|
355
|
+
content: null,
|
|
356
|
+
diagnostics: [outcomeDiagnostic(resolved, context.configPath)],
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
const alias = await findCanonicalAlias(context, catalogs, resolved.value, existing.documentId);
|
|
360
|
+
if (alias !== null) {
|
|
361
|
+
return {
|
|
362
|
+
ok: false,
|
|
363
|
+
status: 'conflict',
|
|
364
|
+
proposal: null,
|
|
365
|
+
content: null,
|
|
366
|
+
diagnostics: [
|
|
367
|
+
diagnostic(diagnosticCodes.document_location_conflict, `Canonical document path is already registered by ${alias.documentId}.`, resolved.value.absolutePath, 'Resolve the canonical location conflict before updating.'),
|
|
368
|
+
],
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
const file = await readKnowledgeFile(resolved.value.absolutePath, MAX_DOCUMENT_BYTES);
|
|
372
|
+
if (!file.ok) {
|
|
373
|
+
return {
|
|
374
|
+
ok: false,
|
|
375
|
+
status: 'conflict',
|
|
376
|
+
proposal: null,
|
|
377
|
+
content: null,
|
|
378
|
+
diagnostics: [outcomeDiagnostic(file, context.configPath)],
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
if (file.value.digest !== input.expectedDigest) {
|
|
382
|
+
return {
|
|
383
|
+
ok: false,
|
|
384
|
+
status: 'conflict',
|
|
385
|
+
proposal: null,
|
|
386
|
+
content: null,
|
|
387
|
+
diagnostics: [
|
|
388
|
+
diagnostic(diagnosticCodes.document_write_conflict, 'The document bytes do not match expectedDigest.', resolved.value.absolutePath, 'Read the document again; the proposal was not written.'),
|
|
389
|
+
],
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
if (existing.owner === 'generator' &&
|
|
393
|
+
existing.digest !== file.value.digest) {
|
|
394
|
+
return {
|
|
395
|
+
ok: false,
|
|
396
|
+
status: 'conflict',
|
|
397
|
+
proposal: null,
|
|
398
|
+
content: null,
|
|
399
|
+
diagnostics: [
|
|
400
|
+
diagnostic(diagnosticCodes.document_generated_modified, 'A generated document was modified by a human; automatic overwrite is blocked.', resolved.value.absolutePath, 'Review the change and use an explicit human-owned document flow.'),
|
|
401
|
+
],
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
const record = {
|
|
405
|
+
...existing,
|
|
406
|
+
digest: nextDigest,
|
|
407
|
+
};
|
|
408
|
+
const invalid = recordDiagnostics(record, context.configPath);
|
|
409
|
+
if (invalid.length > 0) {
|
|
410
|
+
return {
|
|
411
|
+
ok: false,
|
|
412
|
+
status: 'failed',
|
|
413
|
+
proposal: null,
|
|
414
|
+
content: null,
|
|
415
|
+
diagnostics: invalid,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
const oldText = decodeText(file.value.bytes);
|
|
419
|
+
const nextText = decodeText(content);
|
|
420
|
+
const diff = oldText === null || nextText === null
|
|
421
|
+
? '[binary document content changed]'
|
|
422
|
+
: diffPreview(oldText, nextText);
|
|
423
|
+
const proposal = {
|
|
424
|
+
schema: 'pappra/document-write-proposal/1',
|
|
425
|
+
proposalId: randomUUID(),
|
|
426
|
+
createdAt: new Date().toISOString(),
|
|
427
|
+
mode: 'update',
|
|
428
|
+
documentId: record.documentId,
|
|
429
|
+
location: record.location,
|
|
430
|
+
expectedDigest: input.expectedDigest,
|
|
431
|
+
nextDigest,
|
|
432
|
+
catalogDigest: catalogs.catalogDigest,
|
|
433
|
+
rootIdentity: context.rootIdentity,
|
|
434
|
+
record,
|
|
435
|
+
diff,
|
|
436
|
+
};
|
|
437
|
+
const parsed = documentWriteProposalSchema.safeParse(proposal);
|
|
438
|
+
if (!parsed.success) {
|
|
439
|
+
return {
|
|
440
|
+
ok: false,
|
|
441
|
+
status: 'failed',
|
|
442
|
+
proposal: null,
|
|
443
|
+
content: null,
|
|
444
|
+
diagnostics: [
|
|
445
|
+
diagnostic(diagnosticCodes.document_record_invalid, `Write proposal is invalid: ${parsed.error.issues[0]?.message ?? 'unknown issue'}`, context.configPath, 'Fix the proposal inputs.'),
|
|
446
|
+
],
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
ok: true,
|
|
451
|
+
status: 'proposed',
|
|
452
|
+
proposal: parsed.data,
|
|
453
|
+
content,
|
|
454
|
+
diagnostics: [...catalogs.diagnostics],
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
if (input.expectedDigest !== null) {
|
|
458
|
+
return {
|
|
459
|
+
ok: false,
|
|
460
|
+
status: 'conflict',
|
|
461
|
+
proposal: null,
|
|
462
|
+
content: null,
|
|
463
|
+
diagnostics: [
|
|
464
|
+
diagnostic(diagnosticCodes.document_write_conflict, 'A new document must use expectedDigest=null.', context.configPath, 'Do not supply a digest for a file that does not exist yet.'),
|
|
465
|
+
],
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
if (input.location.kind !== 'workspace') {
|
|
469
|
+
return {
|
|
470
|
+
ok: false,
|
|
471
|
+
status: 'conflict',
|
|
472
|
+
proposal: null,
|
|
473
|
+
content: null,
|
|
474
|
+
diagnostics: [
|
|
475
|
+
diagnostic(diagnosticCodes.document_access_denied, 'New documents must be created under the workspace newDocuments directory.', context.configPath, 'Use a workspace location; external repositories are read/update-only by explicit mapping.'),
|
|
476
|
+
],
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
const normalized = normalizePortableRelativePath(input.location.path);
|
|
480
|
+
if (!isWithinNewDocuments(context, normalized)) {
|
|
481
|
+
return {
|
|
482
|
+
ok: false,
|
|
483
|
+
status: 'conflict',
|
|
484
|
+
proposal: null,
|
|
485
|
+
content: null,
|
|
486
|
+
diagnostics: [
|
|
487
|
+
diagnostic(diagnosticCodes.document_path_unsafe, `New document target is outside ${context.config.knowledge.newDocuments}: ${normalized}`, context.configPath, 'Use the configured newDocuments directory; do not scatter files across the workspace.'),
|
|
488
|
+
],
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
const resolved = await resolveKnowledgeLocation(context, input.location, {
|
|
492
|
+
forWrite: true,
|
|
493
|
+
forCreate: true,
|
|
494
|
+
});
|
|
495
|
+
if (!resolved.ok) {
|
|
496
|
+
return {
|
|
497
|
+
ok: false,
|
|
498
|
+
status: 'conflict',
|
|
499
|
+
proposal: null,
|
|
500
|
+
content: null,
|
|
501
|
+
diagnostics: [outcomeDiagnostic(resolved, context.configPath)],
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
const canonicalAlias = await findCanonicalAlias(context, catalogs, resolved.value);
|
|
505
|
+
if (canonicalAlias !== null) {
|
|
506
|
+
return {
|
|
507
|
+
ok: false,
|
|
508
|
+
status: 'conflict',
|
|
509
|
+
proposal: null,
|
|
510
|
+
content: null,
|
|
511
|
+
diagnostics: [
|
|
512
|
+
diagnostic(diagnosticCodes.document_location_conflict, `Canonical document path is already registered by ${canonicalAlias.documentId}.`, resolved.value.absolutePath, 'Use the existing record or a different real file.'),
|
|
513
|
+
],
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
const existingLocation = findRecordByLocation(catalogs, locationKey(input.location));
|
|
517
|
+
if (existingLocation !== null) {
|
|
518
|
+
return {
|
|
519
|
+
ok: false,
|
|
520
|
+
status: 'conflict',
|
|
521
|
+
proposal: null,
|
|
522
|
+
content: null,
|
|
523
|
+
diagnostics: [
|
|
524
|
+
diagnostic(diagnosticCodes.document_location_conflict, `Document location is already registered: ${normalized}`, context.configPath, 'Use proposeWrite with the existing document id instead of creating it again.'),
|
|
525
|
+
],
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
const documentId = input.documentId ?? randomUUID();
|
|
529
|
+
if (findRecord(catalogs, documentId) !== null) {
|
|
530
|
+
return {
|
|
531
|
+
ok: false,
|
|
532
|
+
status: 'conflict',
|
|
533
|
+
proposal: null,
|
|
534
|
+
content: null,
|
|
535
|
+
diagnostics: [
|
|
536
|
+
diagnostic(diagnosticCodes.document_duplicate_id, `Document id is already registered: ${documentId}`, context.configPath, 'Use a new stable id for a new document.'),
|
|
537
|
+
],
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
const visibility = input.visibility ?? defaultVisibility(context);
|
|
541
|
+
const record = {
|
|
542
|
+
schema: 'pappra/document/1',
|
|
543
|
+
documentId,
|
|
544
|
+
location: input.location,
|
|
545
|
+
kind: input.kind,
|
|
546
|
+
lifecycle: 'active',
|
|
547
|
+
maturity: input.maturity,
|
|
548
|
+
taskId: input.taskId ?? null,
|
|
549
|
+
owner: input.owner,
|
|
550
|
+
digest: nextDigest,
|
|
551
|
+
sources: [...(input.sources ?? [])],
|
|
552
|
+
supersededBy: null,
|
|
553
|
+
lastReviewedAt: input.lastReviewedAt ?? null,
|
|
554
|
+
visibility,
|
|
555
|
+
};
|
|
556
|
+
const invalid = recordDiagnostics(record, context.configPath);
|
|
557
|
+
const sourceDiagnostics = validateSources(context, catalogs, record.documentId, record.visibility, record.sources);
|
|
558
|
+
if (invalid.length > 0 || sourceDiagnostics.length > 0) {
|
|
559
|
+
return {
|
|
560
|
+
ok: false,
|
|
561
|
+
status: 'failed',
|
|
562
|
+
proposal: null,
|
|
563
|
+
content: null,
|
|
564
|
+
diagnostics: [...invalid, ...sourceDiagnostics],
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
if (input.documentId !== undefined && input.documentId !== documentId) {
|
|
568
|
+
return {
|
|
569
|
+
ok: false,
|
|
570
|
+
status: 'failed',
|
|
571
|
+
proposal: null,
|
|
572
|
+
content: null,
|
|
573
|
+
diagnostics: [
|
|
574
|
+
diagnostic(diagnosticCodes.document_record_invalid, 'Document id does not match the proposal record.', context.configPath, 'Check the supplied document id.'),
|
|
575
|
+
],
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
const proposal = {
|
|
579
|
+
schema: 'pappra/document-write-proposal/1',
|
|
580
|
+
proposalId: randomUUID(),
|
|
581
|
+
createdAt: new Date().toISOString(),
|
|
582
|
+
mode: 'create',
|
|
583
|
+
documentId: record.documentId,
|
|
584
|
+
location: record.location,
|
|
585
|
+
expectedDigest: null,
|
|
586
|
+
nextDigest,
|
|
587
|
+
catalogDigest: catalogs.catalogDigest,
|
|
588
|
+
rootIdentity: context.rootIdentity,
|
|
589
|
+
record,
|
|
590
|
+
diff: diffPreview(null, decodeText(content) ?? '[binary document content]'),
|
|
591
|
+
};
|
|
592
|
+
const parsed = documentWriteProposalSchema.safeParse(proposal);
|
|
593
|
+
if (!parsed.success) {
|
|
594
|
+
return {
|
|
595
|
+
ok: false,
|
|
596
|
+
status: 'failed',
|
|
597
|
+
proposal: null,
|
|
598
|
+
content: null,
|
|
599
|
+
diagnostics: [
|
|
600
|
+
diagnostic(diagnosticCodes.document_record_invalid, `Write proposal is invalid: ${parsed.error.issues[0]?.message ?? 'unknown issue'}`, context.configPath, 'Fix the proposal inputs.'),
|
|
601
|
+
],
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
ok: true,
|
|
606
|
+
status: 'proposed',
|
|
607
|
+
proposal: parsed.data,
|
|
608
|
+
content,
|
|
609
|
+
diagnostics: [...catalogs.diagnostics],
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
async function removeCreatedIfUnchanged(path, identity, expectedBytes) {
|
|
613
|
+
const observation = await observePath(path).catch(() => null);
|
|
614
|
+
if (observation === null ||
|
|
615
|
+
observation.stats === null ||
|
|
616
|
+
observation.stats.dev !== identity.dev ||
|
|
617
|
+
observation.stats.ino !== identity.ino) {
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
const current = await readFileBounded(path, Math.max(1, expectedBytes.length));
|
|
621
|
+
if (!current.ok ||
|
|
622
|
+
current.value.bytes.length > expectedBytes.length ||
|
|
623
|
+
!current.value.bytes.equals(expectedBytes.subarray(0, current.value.bytes.length))) {
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
await unlink(path);
|
|
628
|
+
return true;
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
async function writeFileExclusive(path, bytes, options) {
|
|
635
|
+
let handle;
|
|
636
|
+
let created = false;
|
|
637
|
+
let identity = null;
|
|
638
|
+
try {
|
|
639
|
+
handle = await open(path, 'wx', 0o600);
|
|
640
|
+
created = true;
|
|
641
|
+
const stats = await handle.stat();
|
|
642
|
+
identity = { dev: stats.dev, ino: stats.ino };
|
|
643
|
+
await handle.writeFile(bytes);
|
|
644
|
+
await options.beforeFileSync?.(path);
|
|
645
|
+
if (options.failFileSync === true) {
|
|
646
|
+
throw new Error('Injected file sync failure');
|
|
647
|
+
}
|
|
648
|
+
await handle.sync();
|
|
649
|
+
await handle.close();
|
|
650
|
+
handle = undefined;
|
|
651
|
+
return { ok: true, digest: sha256Digest(bytes) };
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
await handle?.close().catch(() => undefined);
|
|
655
|
+
const code = typeof error === 'object' &&
|
|
656
|
+
error !== null &&
|
|
657
|
+
'code' in error &&
|
|
658
|
+
typeof error.code === 'string'
|
|
659
|
+
? error.code
|
|
660
|
+
: null;
|
|
661
|
+
if (identity === null) {
|
|
662
|
+
return {
|
|
663
|
+
ok: false,
|
|
664
|
+
status: created ? 'partial' : code === 'EEXIST' ? 'conflict' : 'failed',
|
|
665
|
+
code: code === 'EEXIST'
|
|
666
|
+
? diagnosticCodes.document_already_registered
|
|
667
|
+
: diagnosticCodes.fileUnreadable,
|
|
668
|
+
message: code === 'EEXIST'
|
|
669
|
+
? `Document target already exists: ${path}`
|
|
670
|
+
: `Cannot create document ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
671
|
+
residualPaths: created ? [path] : [],
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
const cleaned = await removeCreatedIfUnchanged(path, identity, bytes);
|
|
675
|
+
return {
|
|
676
|
+
ok: false,
|
|
677
|
+
status: cleaned ? 'failed' : 'partial',
|
|
678
|
+
code: diagnosticCodes.fileUnreadable,
|
|
679
|
+
message: `Writing document ${path} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
680
|
+
residualPaths: cleaned ? [] : [path],
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
async function replaceFileAtomic(path, bytes, expectedDigest) {
|
|
685
|
+
const temporary = `${path}.tmp-${randomUUID()}`;
|
|
686
|
+
let handle;
|
|
687
|
+
try {
|
|
688
|
+
handle = await open(temporary, 'wx', 0o600);
|
|
689
|
+
await handle.writeFile(bytes);
|
|
690
|
+
await handle.sync();
|
|
691
|
+
await handle.close();
|
|
692
|
+
handle = undefined;
|
|
693
|
+
const current = await readFileBounded(path, MAX_DOCUMENT_BYTES);
|
|
694
|
+
if (!current.ok) {
|
|
695
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
696
|
+
return {
|
|
697
|
+
ok: false,
|
|
698
|
+
status: 'failed',
|
|
699
|
+
code: current.code,
|
|
700
|
+
message: current.message,
|
|
701
|
+
residualPaths: [],
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
if (current.value.digest !== expectedDigest) {
|
|
705
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
706
|
+
return {
|
|
707
|
+
ok: false,
|
|
708
|
+
status: 'conflict',
|
|
709
|
+
code: diagnosticCodes.document_write_conflict,
|
|
710
|
+
message: `Document changed before commit: ${path}`,
|
|
711
|
+
residualPaths: [],
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
await rename(temporary, path);
|
|
715
|
+
return { ok: true, digest: sha256Digest(bytes) };
|
|
716
|
+
}
|
|
717
|
+
catch (error) {
|
|
718
|
+
await handle?.close().catch(() => undefined);
|
|
719
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
720
|
+
return {
|
|
721
|
+
ok: false,
|
|
722
|
+
status: 'failed',
|
|
723
|
+
code: diagnosticCodes.fileUnreadable,
|
|
724
|
+
message: `Cannot replace document ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
725
|
+
residualPaths: [],
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
function journal(operationId, operation, stage, documentId, targetPath, catalogScope, expectedDigest, fileDigest, catalogDigest, createdAt, updatedAt, residualPaths, detail) {
|
|
730
|
+
return {
|
|
731
|
+
schema: 'pappra/knowledge-journal/1',
|
|
732
|
+
operationId,
|
|
733
|
+
operation,
|
|
734
|
+
stage,
|
|
735
|
+
documentId,
|
|
736
|
+
targetPath,
|
|
737
|
+
catalogScope,
|
|
738
|
+
expectedDigest,
|
|
739
|
+
fileDigest,
|
|
740
|
+
catalogDigest,
|
|
741
|
+
createdAt,
|
|
742
|
+
updatedAt,
|
|
743
|
+
residualPaths: [...residualPaths],
|
|
744
|
+
detail,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
export async function applyWrite(input, root, options = {}) {
|
|
748
|
+
const parsed = documentWriteProposalSchema.safeParse(input.proposal);
|
|
749
|
+
if (!parsed.success) {
|
|
750
|
+
return {
|
|
751
|
+
ok: false,
|
|
752
|
+
status: 'failed',
|
|
753
|
+
record: null,
|
|
754
|
+
diagnostics: [
|
|
755
|
+
diagnostic(diagnosticCodes.document_record_invalid, `Write proposal is invalid: ${parsed.error.issues[0]?.message ?? 'unknown issue'}`, 'knowledge proposal', 'Regenerate the proposal.'),
|
|
756
|
+
],
|
|
757
|
+
residualPaths: [],
|
|
758
|
+
journalPath: null,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
const proposal = parsed.data;
|
|
762
|
+
const content = contentBuffer(input.content);
|
|
763
|
+
if (content.length > MAX_DOCUMENT_BYTES) {
|
|
764
|
+
return {
|
|
765
|
+
ok: false,
|
|
766
|
+
status: 'failed',
|
|
767
|
+
record: null,
|
|
768
|
+
diagnostics: [
|
|
769
|
+
diagnostic(diagnosticCodes.fileTooLarge, `Document content exceeds ${MAX_DOCUMENT_BYTES} bytes.`, 'knowledge proposal', 'Reduce the document size; Pappra does not truncate knowledge.'),
|
|
770
|
+
],
|
|
771
|
+
residualPaths: [],
|
|
772
|
+
journalPath: null,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
if (sha256Digest(content) !== proposal.nextDigest) {
|
|
776
|
+
return {
|
|
777
|
+
ok: false,
|
|
778
|
+
status: 'conflict',
|
|
779
|
+
record: null,
|
|
780
|
+
diagnostics: [
|
|
781
|
+
diagnostic(diagnosticCodes.document_proposal_stale, 'Proposal content does not match its recorded nextDigest.', 'knowledge proposal', 'Regenerate the proposal instead of applying modified bytes.'),
|
|
782
|
+
],
|
|
783
|
+
residualPaths: [],
|
|
784
|
+
journalPath: null,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
const loaded = await loadKnowledgeContext(root);
|
|
788
|
+
if (!loaded.ok || loaded.context === null) {
|
|
789
|
+
return {
|
|
790
|
+
ok: false,
|
|
791
|
+
status: 'failed',
|
|
792
|
+
record: null,
|
|
793
|
+
diagnostics: [...loaded.diagnostics],
|
|
794
|
+
residualPaths: [],
|
|
795
|
+
journalPath: null,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
return applyWriteAt(loaded.context, proposal, content, options);
|
|
799
|
+
}
|
|
800
|
+
export async function applyWriteAt(inputContext, proposal, content, options = {}) {
|
|
801
|
+
const initialCatalogs = await loadKnowledgeCatalogs(inputContext);
|
|
802
|
+
if (!initialCatalogs.ok) {
|
|
803
|
+
return {
|
|
804
|
+
ok: false,
|
|
805
|
+
status: 'conflict',
|
|
806
|
+
record: null,
|
|
807
|
+
diagnostics: [...initialCatalogs.diagnostics],
|
|
808
|
+
residualPaths: [],
|
|
809
|
+
journalPath: null,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
if (!sameRootIdentity(inputContext, proposal.rootIdentity)) {
|
|
813
|
+
return {
|
|
814
|
+
ok: false,
|
|
815
|
+
status: 'stale',
|
|
816
|
+
record: null,
|
|
817
|
+
diagnostics: [
|
|
818
|
+
diagnostic(diagnosticCodes.document_proposal_stale, 'Workspace root identity changed since the proposal was created.', inputContext.root, 'Regenerate the proposal against the current workspace root.'),
|
|
819
|
+
],
|
|
820
|
+
residualPaths: [],
|
|
821
|
+
journalPath: null,
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
const lock = await acquireKnowledgeLock(inputContext, 'write-document');
|
|
825
|
+
if (!lock.ok || lock.lock === null) {
|
|
826
|
+
return {
|
|
827
|
+
ok: false,
|
|
828
|
+
status: lock.status === 'locked' ? 'locked' : 'failed',
|
|
829
|
+
record: null,
|
|
830
|
+
diagnostics: [
|
|
831
|
+
diagnostic(lock.status === 'locked'
|
|
832
|
+
? diagnosticCodes.document_locked
|
|
833
|
+
: diagnosticCodes.document_write_conflict, lock.message ?? 'Knowledge catalog is locked.', lock.path, 'Retry after the current knowledge operation finishes.'),
|
|
834
|
+
],
|
|
835
|
+
residualPaths: [],
|
|
836
|
+
journalPath: null,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
const operationId = randomUUID();
|
|
840
|
+
const journalPath = knowledgeJournalPath(inputContext, operationId);
|
|
841
|
+
let residualPaths = [];
|
|
842
|
+
const createdAt = new Date().toISOString();
|
|
843
|
+
const applyResult = await (async () => {
|
|
844
|
+
const reloaded = await loadKnowledgeContext(inputContext.root);
|
|
845
|
+
if (!reloaded.ok || reloaded.context === null) {
|
|
846
|
+
return {
|
|
847
|
+
ok: false,
|
|
848
|
+
status: 'failed',
|
|
849
|
+
record: null,
|
|
850
|
+
diagnostics: [...reloaded.diagnostics],
|
|
851
|
+
residualPaths: [],
|
|
852
|
+
journalPath,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
const context = reloaded.context;
|
|
856
|
+
const catalogs = await loadKnowledgeCatalogs(context);
|
|
857
|
+
if (!catalogs.ok) {
|
|
858
|
+
return {
|
|
859
|
+
ok: false,
|
|
860
|
+
status: 'conflict',
|
|
861
|
+
record: null,
|
|
862
|
+
diagnostics: [...catalogs.diagnostics],
|
|
863
|
+
residualPaths: [],
|
|
864
|
+
journalPath,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
if (catalogs.catalogDigest !== proposal.catalogDigest) {
|
|
868
|
+
return {
|
|
869
|
+
ok: false,
|
|
870
|
+
status: 'stale',
|
|
871
|
+
record: null,
|
|
872
|
+
diagnostics: [
|
|
873
|
+
...catalogs.diagnostics,
|
|
874
|
+
diagnostic(diagnosticCodes.document_proposal_stale, 'Document catalog changed since the proposal was created.', context.configPath, 'Regenerate the proposal against the current catalog.'),
|
|
875
|
+
],
|
|
876
|
+
residualPaths: [],
|
|
877
|
+
journalPath,
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
if (!sameRootIdentity(context, proposal.rootIdentity)) {
|
|
881
|
+
return {
|
|
882
|
+
ok: false,
|
|
883
|
+
status: 'stale',
|
|
884
|
+
record: null,
|
|
885
|
+
diagnostics: [
|
|
886
|
+
diagnostic(diagnosticCodes.document_proposal_stale, 'Workspace root identity changed before the proposal could be applied.', context.root, 'Regenerate the proposal.'),
|
|
887
|
+
],
|
|
888
|
+
residualPaths: [],
|
|
889
|
+
journalPath,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
const resolved = await resolveKnowledgeLocation(context, proposal.location, {
|
|
893
|
+
forWrite: true,
|
|
894
|
+
forCreate: proposal.mode === 'create',
|
|
895
|
+
});
|
|
896
|
+
if (!resolved.ok) {
|
|
897
|
+
return {
|
|
898
|
+
ok: false,
|
|
899
|
+
status: 'conflict',
|
|
900
|
+
record: null,
|
|
901
|
+
diagnostics: [outcomeDiagnostic(resolved, context.configPath)],
|
|
902
|
+
residualPaths: [],
|
|
903
|
+
journalPath,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
const alias = await findCanonicalAlias(context, catalogs, resolved.value, proposal.mode === 'update' ? proposal.documentId : null);
|
|
907
|
+
if (alias !== null) {
|
|
908
|
+
return {
|
|
909
|
+
ok: false,
|
|
910
|
+
status: 'conflict',
|
|
911
|
+
record: null,
|
|
912
|
+
diagnostics: [
|
|
913
|
+
diagnostic(diagnosticCodes.document_location_conflict, `Canonical document path is already registered by ${alias.documentId}.`, resolved.value.absolutePath, 'Resolve the canonical location conflict before applying.'),
|
|
914
|
+
],
|
|
915
|
+
residualPaths: [],
|
|
916
|
+
journalPath,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
const existing = findRecord(catalogs, proposal.documentId);
|
|
920
|
+
if (proposal.mode === 'create') {
|
|
921
|
+
if (existing !== null) {
|
|
922
|
+
return {
|
|
923
|
+
ok: false,
|
|
924
|
+
status: 'conflict',
|
|
925
|
+
record: null,
|
|
926
|
+
diagnostics: [
|
|
927
|
+
diagnostic(diagnosticCodes.document_duplicate_id, 'A record with this document id already exists.', context.configPath, 'Regenerate the proposal; create never overwrites an existing record.'),
|
|
928
|
+
],
|
|
929
|
+
residualPaths: [],
|
|
930
|
+
journalPath,
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
if (findRecordByLocation(catalogs, locationKey(proposal.location)) !== null) {
|
|
934
|
+
return {
|
|
935
|
+
ok: false,
|
|
936
|
+
status: 'conflict',
|
|
937
|
+
record: null,
|
|
938
|
+
diagnostics: [
|
|
939
|
+
diagnostic(diagnosticCodes.document_location_conflict, 'The location is already registered.', context.configPath, 'Regenerate the proposal against the existing record.'),
|
|
940
|
+
],
|
|
941
|
+
residualPaths: [],
|
|
942
|
+
journalPath,
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
if (proposal.location.kind !== 'workspace' ||
|
|
946
|
+
!isWithinNewDocuments(context, resolved.value.normalizedPath)) {
|
|
947
|
+
return {
|
|
948
|
+
ok: false,
|
|
949
|
+
status: 'conflict',
|
|
950
|
+
record: null,
|
|
951
|
+
diagnostics: [
|
|
952
|
+
diagnostic(diagnosticCodes.document_path_unsafe, 'New documents must stay under the configured newDocuments directory.', context.configPath, 'Regenerate the proposal with a workspace location inside newDocuments.'),
|
|
953
|
+
],
|
|
954
|
+
residualPaths: [],
|
|
955
|
+
journalPath,
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
else {
|
|
960
|
+
if (existing === null || proposal.expectedDigest === null) {
|
|
961
|
+
return {
|
|
962
|
+
ok: false,
|
|
963
|
+
status: 'conflict',
|
|
964
|
+
record: null,
|
|
965
|
+
diagnostics: [
|
|
966
|
+
diagnostic(diagnosticCodes.document_not_found, 'The document to update is missing or ambiguous.', context.configPath, 'Regenerate the proposal from the current record.'),
|
|
967
|
+
],
|
|
968
|
+
residualPaths: [],
|
|
969
|
+
journalPath,
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
if (!sameRecordForUpdate(existing, proposal.record)) {
|
|
973
|
+
return {
|
|
974
|
+
ok: false,
|
|
975
|
+
status: 'conflict',
|
|
976
|
+
record: null,
|
|
977
|
+
diagnostics: [
|
|
978
|
+
diagnostic(diagnosticCodes.document_write_conflict, 'The proposal changes metadata outside the controlled write path.', context.configPath, 'Regenerate the proposal; visibility and lifecycle require explicit operations.'),
|
|
979
|
+
],
|
|
980
|
+
residualPaths: [],
|
|
981
|
+
journalPath,
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
const file = await readKnowledgeFile(resolved.value.absolutePath, MAX_DOCUMENT_BYTES);
|
|
985
|
+
if (!file.ok) {
|
|
986
|
+
return {
|
|
987
|
+
ok: false,
|
|
988
|
+
status: 'conflict',
|
|
989
|
+
record: null,
|
|
990
|
+
diagnostics: [outcomeDiagnostic(file, context.configPath)],
|
|
991
|
+
residualPaths: [],
|
|
992
|
+
journalPath,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
if (file.value.digest !== proposal.expectedDigest) {
|
|
996
|
+
return {
|
|
997
|
+
ok: false,
|
|
998
|
+
status: 'conflict',
|
|
999
|
+
record: null,
|
|
1000
|
+
diagnostics: [
|
|
1001
|
+
diagnostic(diagnosticCodes.document_write_conflict, 'Document bytes no longer match the proposal expectedDigest.', resolved.value.absolutePath, 'Regenerate the proposal; no file was written.'),
|
|
1002
|
+
],
|
|
1003
|
+
residualPaths: [],
|
|
1004
|
+
journalPath,
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
if (existing.owner === 'generator' &&
|
|
1008
|
+
existing.digest !== file.value.digest) {
|
|
1009
|
+
return {
|
|
1010
|
+
ok: false,
|
|
1011
|
+
status: 'conflict',
|
|
1012
|
+
record: null,
|
|
1013
|
+
diagnostics: [
|
|
1014
|
+
diagnostic(diagnosticCodes.document_generated_modified, 'A generated document was modified; automatic overwrite is blocked.', resolved.value.absolutePath, 'Review the change and use an explicit human-owned flow.'),
|
|
1015
|
+
],
|
|
1016
|
+
residualPaths: [],
|
|
1017
|
+
journalPath,
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
const proposedRecord = documentRecordSchema.safeParse(proposal.record);
|
|
1022
|
+
if (!proposedRecord.success) {
|
|
1023
|
+
return {
|
|
1024
|
+
ok: false,
|
|
1025
|
+
status: 'failed',
|
|
1026
|
+
record: null,
|
|
1027
|
+
diagnostics: [
|
|
1028
|
+
diagnostic(diagnosticCodes.document_record_invalid, `Proposal record is invalid: ${proposedRecord.error.issues[0]?.message ?? 'unknown issue'}`, context.configPath, 'Regenerate the proposal.'),
|
|
1029
|
+
],
|
|
1030
|
+
residualPaths: [],
|
|
1031
|
+
journalPath,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
const sourceDiagnostics = validateSources(context, catalogs, proposal.record.documentId, proposal.record.visibility, proposal.record.sources);
|
|
1035
|
+
if (sourceDiagnostics.length > 0) {
|
|
1036
|
+
return {
|
|
1037
|
+
ok: false,
|
|
1038
|
+
status: 'conflict',
|
|
1039
|
+
record: null,
|
|
1040
|
+
diagnostics: [...catalogs.diagnostics, ...sourceDiagnostics],
|
|
1041
|
+
residualPaths: [],
|
|
1042
|
+
journalPath,
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
const initialJournal = journal(operationId, 'write-document', 'prepared', proposal.documentId, resolved.value.absolutePath, catalogScopeFor(proposal.record), proposal.expectedDigest, proposal.nextDigest, catalogs.catalogDigest, createdAt, createdAt, [], null);
|
|
1046
|
+
const journalWritten = await writeKnowledgeJournal(context, initialJournal);
|
|
1047
|
+
if (!journalWritten.ok) {
|
|
1048
|
+
return {
|
|
1049
|
+
ok: false,
|
|
1050
|
+
status: 'failed',
|
|
1051
|
+
record: null,
|
|
1052
|
+
diagnostics: [outcomeDiagnostic(journalWritten, journalPath)],
|
|
1053
|
+
residualPaths: [],
|
|
1054
|
+
journalPath,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
if (proposal.mode === 'create') {
|
|
1058
|
+
try {
|
|
1059
|
+
await mkdir(dirname(resolved.value.absolutePath), { recursive: true });
|
|
1060
|
+
}
|
|
1061
|
+
catch (error) {
|
|
1062
|
+
return {
|
|
1063
|
+
ok: false,
|
|
1064
|
+
status: 'failed',
|
|
1065
|
+
record: null,
|
|
1066
|
+
diagnostics: [
|
|
1067
|
+
diagnostic(diagnosticCodes.fileUnreadable, `Cannot create document parent directory: ${error instanceof Error ? error.message : String(error)}`, resolved.value.absolutePath, 'Inspect permissions; no document file or catalog was written.'),
|
|
1068
|
+
],
|
|
1069
|
+
residualPaths: [],
|
|
1070
|
+
journalPath,
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
const fileWritten = await writeFileExclusive(resolved.value.absolutePath, content, options);
|
|
1074
|
+
if (!fileWritten.ok) {
|
|
1075
|
+
await writeKnowledgeJournal(context, {
|
|
1076
|
+
...initialJournal,
|
|
1077
|
+
stage: 'failed',
|
|
1078
|
+
updatedAt: new Date().toISOString(),
|
|
1079
|
+
residualPaths: [...fileWritten.residualPaths],
|
|
1080
|
+
detail: fileWritten.message,
|
|
1081
|
+
});
|
|
1082
|
+
return {
|
|
1083
|
+
ok: false,
|
|
1084
|
+
status: fileWritten.status === 'partial'
|
|
1085
|
+
? 'partial'
|
|
1086
|
+
: fileWritten.status === 'conflict'
|
|
1087
|
+
? 'conflict'
|
|
1088
|
+
: 'failed',
|
|
1089
|
+
record: null,
|
|
1090
|
+
diagnostics: [
|
|
1091
|
+
diagnostic(fileWritten.code, fileWritten.message, resolved.value.absolutePath, fileWritten.status === 'partial'
|
|
1092
|
+
? 'The incomplete file was preserved and must be inspected/removed explicitly.'
|
|
1093
|
+
: 'No document file was committed.'),
|
|
1094
|
+
],
|
|
1095
|
+
residualPaths: [...fileWritten.residualPaths],
|
|
1096
|
+
journalPath,
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
else {
|
|
1101
|
+
const expectedFileDigest = proposal.expectedDigest;
|
|
1102
|
+
if (expectedFileDigest === null) {
|
|
1103
|
+
return {
|
|
1104
|
+
ok: false,
|
|
1105
|
+
status: 'stale',
|
|
1106
|
+
record: null,
|
|
1107
|
+
diagnostics: [
|
|
1108
|
+
diagnostic(diagnosticCodes.document_proposal_stale, 'Update proposal has no expected file digest.', resolved.value.absolutePath, 'Regenerate the proposal.'),
|
|
1109
|
+
],
|
|
1110
|
+
residualPaths: [],
|
|
1111
|
+
journalPath,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
const fileWritten = await replaceFileAtomic(resolved.value.absolutePath, content, expectedFileDigest);
|
|
1115
|
+
if (!fileWritten.ok) {
|
|
1116
|
+
await writeKnowledgeJournal(context, {
|
|
1117
|
+
...initialJournal,
|
|
1118
|
+
stage: 'failed',
|
|
1119
|
+
updatedAt: new Date().toISOString(),
|
|
1120
|
+
residualPaths: [...fileWritten.residualPaths],
|
|
1121
|
+
detail: fileWritten.message,
|
|
1122
|
+
});
|
|
1123
|
+
return {
|
|
1124
|
+
ok: false,
|
|
1125
|
+
status: fileWritten.status === 'conflict' ? 'conflict' : 'failed',
|
|
1126
|
+
record: null,
|
|
1127
|
+
diagnostics: [
|
|
1128
|
+
diagnostic(fileWritten.code, fileWritten.message, resolved.value.absolutePath, 'The existing document file was preserved; no catalog replacement was committed.'),
|
|
1129
|
+
],
|
|
1130
|
+
residualPaths: [...fileWritten.residualPaths],
|
|
1131
|
+
journalPath,
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
residualPaths = [resolved.value.absolutePath];
|
|
1136
|
+
const fileStage = journal(operationId, 'write-document', 'file-written', proposal.documentId, resolved.value.absolutePath, catalogScopeFor(proposal.record), proposal.expectedDigest, proposal.nextDigest, catalogs.catalogDigest, createdAt, new Date().toISOString(), residualPaths, 'Document bytes were written; the catalog update is still pending.');
|
|
1137
|
+
const fileJournal = await writeKnowledgeJournal(context, fileStage);
|
|
1138
|
+
if (!fileJournal.ok) {
|
|
1139
|
+
return {
|
|
1140
|
+
ok: false,
|
|
1141
|
+
status: 'partial',
|
|
1142
|
+
record: null,
|
|
1143
|
+
diagnostics: [outcomeDiagnostic(fileJournal, journalPath)],
|
|
1144
|
+
residualPaths,
|
|
1145
|
+
journalPath,
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
const committedFile = await readKnowledgeFile(resolved.value.absolutePath, MAX_DOCUMENT_BYTES);
|
|
1149
|
+
const latestCatalogs = await loadKnowledgeCatalogs(context);
|
|
1150
|
+
if (!committedFile.ok ||
|
|
1151
|
+
committedFile.value.digest !== proposal.nextDigest ||
|
|
1152
|
+
!latestCatalogs.ok ||
|
|
1153
|
+
latestCatalogs.catalogDigest !== proposal.catalogDigest) {
|
|
1154
|
+
const failedStage = journal(operationId, 'write-document', 'failed', proposal.documentId, resolved.value.absolutePath, catalogScopeFor(proposal.record), proposal.expectedDigest, proposal.nextDigest, catalogs.catalogDigest, createdAt, new Date().toISOString(), residualPaths, 'Document file or catalog changed before the catalog commit.');
|
|
1155
|
+
await writeKnowledgeJournal(context, failedStage);
|
|
1156
|
+
return {
|
|
1157
|
+
ok: false,
|
|
1158
|
+
status: 'partial',
|
|
1159
|
+
record: null,
|
|
1160
|
+
diagnostics: [
|
|
1161
|
+
...latestCatalogs.diagnostics,
|
|
1162
|
+
diagnostic(diagnosticCodes.document_write_partial, 'Document file or catalog changed before the catalog commit; the file was preserved and the catalog was not updated.', resolved.value.absolutePath, 'Inspect the journal and reconcile the file/catalog state explicitly.'),
|
|
1163
|
+
],
|
|
1164
|
+
residualPaths,
|
|
1165
|
+
journalPath,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
const nextCatalog = setCatalogRecord(context, catalogs, proposedRecord.data);
|
|
1169
|
+
const catalogWritten = await writeCatalogFile(context, nextCatalog.scope, nextCatalog.catalog, catalogDigestFor(catalogs, nextCatalog.scope));
|
|
1170
|
+
if (!catalogWritten.ok) {
|
|
1171
|
+
const failedStage = journal(operationId, 'write-document', 'failed', proposal.documentId, resolved.value.absolutePath, nextCatalog.scope, proposal.expectedDigest, proposal.nextDigest, catalogs.catalogDigest, createdAt, new Date().toISOString(), residualPaths, 'Document file was written but the catalog update failed.');
|
|
1172
|
+
await writeKnowledgeJournal(context, failedStage);
|
|
1173
|
+
return {
|
|
1174
|
+
ok: false,
|
|
1175
|
+
status: 'partial',
|
|
1176
|
+
record: null,
|
|
1177
|
+
diagnostics: [
|
|
1178
|
+
diagnostic(diagnosticCodes.document_write_partial, 'Document file was written but the catalog update failed.', catalogWritten.message, 'Inspect the journal and re-run an explicit repair; the old catalog entry may still point to the previous digest.'),
|
|
1179
|
+
],
|
|
1180
|
+
residualPaths,
|
|
1181
|
+
journalPath,
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
residualPaths = [];
|
|
1185
|
+
const complete = journal(operationId, 'write-document', 'complete', proposal.documentId, resolved.value.absolutePath, nextCatalog.scope, proposal.expectedDigest, proposal.nextDigest, catalogWritten.value.digest, createdAt, new Date().toISOString(), [], null);
|
|
1186
|
+
const completeJournal = await writeKnowledgeJournal(context, complete);
|
|
1187
|
+
if (!completeJournal.ok) {
|
|
1188
|
+
return {
|
|
1189
|
+
ok: false,
|
|
1190
|
+
status: 'partial',
|
|
1191
|
+
record: proposedRecord.data,
|
|
1192
|
+
diagnostics: [
|
|
1193
|
+
...reloaded.diagnostics,
|
|
1194
|
+
outcomeDiagnostic(completeJournal, journalPath),
|
|
1195
|
+
],
|
|
1196
|
+
residualPaths: [journalPath],
|
|
1197
|
+
journalPath,
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
return {
|
|
1201
|
+
ok: true,
|
|
1202
|
+
status: 'applied',
|
|
1203
|
+
record: proposedRecord.data,
|
|
1204
|
+
diagnostics: [...reloaded.diagnostics],
|
|
1205
|
+
residualPaths,
|
|
1206
|
+
journalPath,
|
|
1207
|
+
};
|
|
1208
|
+
})();
|
|
1209
|
+
const release = await releaseKnowledgeLock(lock.lock, lock.path);
|
|
1210
|
+
if (!release.ok) {
|
|
1211
|
+
return {
|
|
1212
|
+
...applyResult,
|
|
1213
|
+
ok: false,
|
|
1214
|
+
status: applyResult.ok ? 'partial' : applyResult.status,
|
|
1215
|
+
diagnostics: [
|
|
1216
|
+
...applyResult.diagnostics,
|
|
1217
|
+
outcomeDiagnostic(release, lock.path),
|
|
1218
|
+
],
|
|
1219
|
+
residualPaths: [...applyResult.residualPaths, lock.path],
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
return applyResult;
|
|
1223
|
+
}
|