@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,179 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { createDiagnostic } from '../schemas/diagnostics.js';
|
|
3
|
+
import { diagnosticCodes } from '../schemas/errors.js';
|
|
4
|
+
import { inspectMachineConfig, inspectWorkspaceConfig, } from '../workspace/config.js';
|
|
5
|
+
import { discoverRepositories } from '../workspace/repositories.js';
|
|
6
|
+
import { runGit } from '../infra/git.js';
|
|
7
|
+
import { observePath, readFileBounded, sha256Digest } from '../infra/fs.js';
|
|
8
|
+
import { readlink } from 'node:fs/promises';
|
|
9
|
+
import { isPathInside } from '../schemas/paths.js';
|
|
10
|
+
import { CONTINUITY_PAYLOAD_MAX_BYTES } from './paths.js';
|
|
11
|
+
const discoveryOptions = {
|
|
12
|
+
scanDepth: 0,
|
|
13
|
+
maxDirectories: 1,
|
|
14
|
+
maxDocuments: 1,
|
|
15
|
+
maxEntriesPerDirectory: 1,
|
|
16
|
+
gitTimeoutMs: 5_000,
|
|
17
|
+
};
|
|
18
|
+
export async function loadContinuityWorkspace(root) {
|
|
19
|
+
const config = await inspectWorkspaceConfig(root);
|
|
20
|
+
const diagnostics = [...config.diagnostics];
|
|
21
|
+
if (config.value === null) {
|
|
22
|
+
return { ok: false, workspace: null, diagnostics };
|
|
23
|
+
}
|
|
24
|
+
const machine = await inspectMachineConfig(config.root);
|
|
25
|
+
diagnostics.push(...machine.diagnostics);
|
|
26
|
+
const repositories = await discoverRepositories({
|
|
27
|
+
root: config.root,
|
|
28
|
+
config: config.value,
|
|
29
|
+
machine: machine.value,
|
|
30
|
+
configPath: config.path,
|
|
31
|
+
options: discoveryOptions,
|
|
32
|
+
});
|
|
33
|
+
diagnostics.push(...repositories.diagnostics);
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
workspace: {
|
|
37
|
+
root: config.root,
|
|
38
|
+
config: config.value,
|
|
39
|
+
machine: machine.value,
|
|
40
|
+
repositories: repositories.repositories,
|
|
41
|
+
diagnostics,
|
|
42
|
+
},
|
|
43
|
+
diagnostics,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function repoId(repository, index) {
|
|
47
|
+
return (repository.id ?? (index === 0 ? 'workspace' : `workspace-${index + 1}`));
|
|
48
|
+
}
|
|
49
|
+
function repoScope(repository) {
|
|
50
|
+
return repository.external ? 'external' : 'workspace';
|
|
51
|
+
}
|
|
52
|
+
export async function snapshotRepository(workspace, repository, index) {
|
|
53
|
+
const status = await runGit({
|
|
54
|
+
cwd: repository.root,
|
|
55
|
+
args: [
|
|
56
|
+
'status',
|
|
57
|
+
'--porcelain=v1',
|
|
58
|
+
'--untracked-files=all',
|
|
59
|
+
'--',
|
|
60
|
+
'.',
|
|
61
|
+
':(exclude).pappra/local',
|
|
62
|
+
':(exclude).pappra/generated',
|
|
63
|
+
],
|
|
64
|
+
timeoutMs: 5_000,
|
|
65
|
+
maxOutputBytes: 2 * 1024 * 1024,
|
|
66
|
+
});
|
|
67
|
+
const diff = await runGit({
|
|
68
|
+
cwd: repository.root,
|
|
69
|
+
args: [
|
|
70
|
+
'diff',
|
|
71
|
+
'--binary',
|
|
72
|
+
'HEAD',
|
|
73
|
+
'--',
|
|
74
|
+
'.',
|
|
75
|
+
':(exclude).pappra/local',
|
|
76
|
+
':(exclude).pappra/generated',
|
|
77
|
+
],
|
|
78
|
+
timeoutMs: 5_000,
|
|
79
|
+
maxOutputBytes: 4 * 1024 * 1024,
|
|
80
|
+
});
|
|
81
|
+
const statusText = status.ok ? status.value.stdout : '';
|
|
82
|
+
const diffText = diff.ok ? diff.value.stdout : '';
|
|
83
|
+
const dirty = status.ok ? statusText.trim().length > 0 : repository.git.dirty;
|
|
84
|
+
let captureStatus = status.ok && diff.ok ? 'complete' : 'unknown';
|
|
85
|
+
let patchBytes = null;
|
|
86
|
+
if (dirty === true && status.ok && diff.ok) {
|
|
87
|
+
const statusBytes = Buffer.from(statusText, 'utf8');
|
|
88
|
+
const diffBytes = Buffer.from(diffText, 'utf8');
|
|
89
|
+
const parts = [
|
|
90
|
+
Buffer.from(`PAPPRA-UNTRACKED/2\nP ${statusBytes.length} ${diffBytes.length}\n`, 'utf8'),
|
|
91
|
+
statusBytes,
|
|
92
|
+
diffBytes,
|
|
93
|
+
];
|
|
94
|
+
let total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
95
|
+
if (total > CONTINUITY_PAYLOAD_MAX_BYTES) {
|
|
96
|
+
captureStatus = 'partial';
|
|
97
|
+
}
|
|
98
|
+
const untracked = statusText
|
|
99
|
+
.split('\n')
|
|
100
|
+
.filter((line) => line.startsWith('?? '))
|
|
101
|
+
.map((line) => line.slice(3));
|
|
102
|
+
for (const path of untracked) {
|
|
103
|
+
if (total > CONTINUITY_PAYLOAD_MAX_BYTES)
|
|
104
|
+
break;
|
|
105
|
+
const absolute = join(repository.root, path);
|
|
106
|
+
if (!isPathInside(repository.root, absolute)) {
|
|
107
|
+
captureStatus = 'partial';
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const observation = await observePath(absolute).catch(() => null);
|
|
111
|
+
if (observation === null) {
|
|
112
|
+
captureStatus = 'partial';
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
let body;
|
|
116
|
+
let entryKind;
|
|
117
|
+
if (observation.kind === 'file') {
|
|
118
|
+
const read = await readFileBounded(absolute, CONTINUITY_PAYLOAD_MAX_BYTES);
|
|
119
|
+
if (!read.ok) {
|
|
120
|
+
captureStatus = 'partial';
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
body = read.value.bytes;
|
|
124
|
+
entryKind = 'F';
|
|
125
|
+
}
|
|
126
|
+
else if (observation.kind === 'symlink') {
|
|
127
|
+
const target = await readlink(absolute).catch(() => null);
|
|
128
|
+
if (target === null) {
|
|
129
|
+
captureStatus = 'partial';
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
body = Buffer.from(target, 'utf8');
|
|
133
|
+
entryKind = 'L';
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
captureStatus = 'partial';
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const pathBytes = Buffer.from(path, 'utf8');
|
|
140
|
+
// F/L is part of the identity: a regular file body and a symlink target
|
|
141
|
+
// with identical bytes are still different source objects. The target
|
|
142
|
+
// text is read with readlink and never resolved or opened.
|
|
143
|
+
const header = Buffer.from(`${entryKind} ${pathBytes.length} ${body.length}\n`, 'utf8');
|
|
144
|
+
if (total + header.length + pathBytes.length + body.length >
|
|
145
|
+
CONTINUITY_PAYLOAD_MAX_BYTES) {
|
|
146
|
+
captureStatus = 'partial';
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
parts.push(header, pathBytes, body);
|
|
150
|
+
total += header.length + pathBytes.length + body.length;
|
|
151
|
+
}
|
|
152
|
+
patchBytes = Buffer.concat(parts);
|
|
153
|
+
}
|
|
154
|
+
const patchDigest = patchBytes === null ? null : sha256Digest(patchBytes);
|
|
155
|
+
const checkoutId = workspace.machine?.checkoutId ?? sha256Digest(repository.root);
|
|
156
|
+
return {
|
|
157
|
+
record: {
|
|
158
|
+
repoId: repoId(repository, index),
|
|
159
|
+
checkoutId,
|
|
160
|
+
root: repository.root,
|
|
161
|
+
head: repository.git.head,
|
|
162
|
+
branch: repository.git.branch,
|
|
163
|
+
dirty,
|
|
164
|
+
patchDigest,
|
|
165
|
+
captureStatus,
|
|
166
|
+
scope: repoScope(repository),
|
|
167
|
+
},
|
|
168
|
+
patchBytes,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
export function continuityDiagnostic(message, source, hint) {
|
|
172
|
+
return createDiagnostic({
|
|
173
|
+
code: diagnosticCodes.document_path_unsafe,
|
|
174
|
+
severity: 'error',
|
|
175
|
+
message,
|
|
176
|
+
source,
|
|
177
|
+
hint,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { CheckpointService } from './service.js';
|
|
2
|
+
export type { ContinuityServiceOptions, NativeRefInput, ResumeCheckpointInput, ResumeCheckpointResult, SaveCheckpointInput, SaveCheckpointResult, ValidationInput, } from './service.js';
|
|
3
|
+
export type { Checkpoint, CheckpointPointer, ResumePacket, TaskContext, } from '../schemas/continuity.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CheckpointService } from './service.js';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Outcome } from '../schemas/outcome.js';
|
|
2
|
+
/**
|
|
3
|
+
* Shared bound for private recovery payloads. Capture, durable writes and
|
|
4
|
+
* resume reads all use this value; a payload over the bound is captured only
|
|
5
|
+
* partially and must never be reported as complete.
|
|
6
|
+
*/
|
|
7
|
+
export declare const CONTINUITY_PAYLOAD_MAX_BYTES: number;
|
|
8
|
+
export interface ContinuityWriteOptions {
|
|
9
|
+
/** Test/fault-injection seam immediately before an exclusive publication. */
|
|
10
|
+
readonly beforeExclusivePublish?: () => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export interface ContinuityTaskPaths {
|
|
13
|
+
readonly taskDir: string;
|
|
14
|
+
readonly contextPath: string;
|
|
15
|
+
readonly latestPath: string;
|
|
16
|
+
readonly checkpointsDir: string;
|
|
17
|
+
readonly patchesDir: string;
|
|
18
|
+
readonly lockPath: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function continuityTaskPaths(root: string, taskId: string): ContinuityTaskPaths;
|
|
21
|
+
export declare function ensureContinuityBoundary(root: string, path: string): Promise<Outcome<null>>;
|
|
22
|
+
export declare function ensurePrivateTaskBoundary(root: string, taskId: string, path: string): Promise<Outcome<null>>;
|
|
23
|
+
export declare function writeContinuityBytes(root: string, taskId: string, path: string, bytes: Buffer, exclusive: boolean, options?: ContinuityWriteOptions): Promise<Outcome<{
|
|
24
|
+
readonly digest: string;
|
|
25
|
+
}>>;
|
|
26
|
+
/**
|
|
27
|
+
* Replaces a file only if its current bytes still have the expected digest.
|
|
28
|
+
* The comparison happens after the temporary bytes are durable and immediately
|
|
29
|
+
* before rename; it is cooperative CAS, not a filesystem-wide transaction.
|
|
30
|
+
*/
|
|
31
|
+
export declare function replaceContinuityBytesIfDigest(root: string, taskId: string, path: string, bytes: Buffer, expectedDigest: string | null): Promise<Outcome<{
|
|
32
|
+
readonly digest: string;
|
|
33
|
+
}>>;
|
|
34
|
+
export declare function readContinuityText(root: string, taskId: string, path: string): Promise<Outcome<string>>;
|
|
35
|
+
export declare function readContinuityBytes(root: string, taskId: string, path: string): Promise<Outcome<Buffer>>;
|
|
36
|
+
export declare function removeContinuityFile(root: string, taskId: string, path: string): Promise<void>;
|
|
37
|
+
export declare function readArtifactPath(root: string, artifactPath: string): Promise<Outcome<string>>;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { link, mkdir, open, rename, rm, unlink } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { errorMessage, failed, succeeded } from '../schemas/outcome.js';
|
|
5
|
+
import { diagnosticCodes } from '../schemas/errors.js';
|
|
6
|
+
import { isPathInside, resolvePortableRelativePath, samePath, } from '../schemas/paths.js';
|
|
7
|
+
import { observePath, readFileBounded, readTextFileBounded, resolveRealpath, sha256Digest, } from '../infra/fs.js';
|
|
8
|
+
import { resolveWorkspaceRoot } from '../workspace/resolve.js';
|
|
9
|
+
const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
10
|
+
/**
|
|
11
|
+
* Shared bound for private recovery payloads. Capture, durable writes and
|
|
12
|
+
* resume reads all use this value; a payload over the bound is captured only
|
|
13
|
+
* partially and must never be reported as complete.
|
|
14
|
+
*/
|
|
15
|
+
export const CONTINUITY_PAYLOAD_MAX_BYTES = 16 * 1024 * 1024;
|
|
16
|
+
export function continuityTaskPaths(root, taskId) {
|
|
17
|
+
if (!TASK_ID.test(taskId) || taskId.length > 64) {
|
|
18
|
+
throw new RangeError(`Invalid task id: ${taskId}`);
|
|
19
|
+
}
|
|
20
|
+
const taskDir = join(root, '.pappra', 'local', 'tasks', taskId);
|
|
21
|
+
return {
|
|
22
|
+
taskDir,
|
|
23
|
+
contextPath: join(taskDir, 'task-context.json'),
|
|
24
|
+
latestPath: join(taskDir, 'latest.json'),
|
|
25
|
+
checkpointsDir: join(taskDir, 'checkpoints'),
|
|
26
|
+
patchesDir: join(taskDir, 'patches'),
|
|
27
|
+
lockPath: join(taskDir, 'checkpoint.lock'),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
async function checkCanonicalAncestor(realRoot, target, path) {
|
|
31
|
+
let current = path;
|
|
32
|
+
for (;;) {
|
|
33
|
+
let observation;
|
|
34
|
+
try {
|
|
35
|
+
observation = await observePath(current);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
return failed(diagnosticCodes.fileUnreadable, `Cannot inspect continuity path ${current}: ${errorMessage(error)}`, {
|
|
39
|
+
nextAction: 'Inspect permissions; no private state was read or written.',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (observation.kind !== 'missing') {
|
|
43
|
+
const isFinal = samePath(current, path);
|
|
44
|
+
if (observation.kind === 'symlink') {
|
|
45
|
+
return failed(diagnosticCodes.document_path_unsafe, `Continuity path is a symlink: ${current}`, {
|
|
46
|
+
nextAction: 'Use regular private state files; symlinked continuity state is not followed.',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (!isFinal && observation.kind !== 'directory') {
|
|
50
|
+
return failed(diagnosticCodes.document_path_unsafe, `Continuity parent is not a directory: ${current}`, {
|
|
51
|
+
nextAction: 'Inspect the private task directory; no write was made.',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const real = await resolveRealpath(current);
|
|
55
|
+
if (real === null ||
|
|
56
|
+
!isPathInside(realRoot, real) ||
|
|
57
|
+
(!isPathInside(real, target) && !isPathInside(target, real))) {
|
|
58
|
+
return failed(diagnosticCodes.document_path_unsafe, `Continuity path resolves outside the private task namespace: ${current}`, {
|
|
59
|
+
nextAction: 'Inspect or remove the redirected state path explicitly; Pappra does not repair it.',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return succeeded(null);
|
|
63
|
+
}
|
|
64
|
+
const parent = dirname(current);
|
|
65
|
+
if (samePath(parent, current)) {
|
|
66
|
+
return failed(diagnosticCodes.document_path_unsafe, `Cannot find an existing ancestor for ${path}`, { nextAction: 'Use a workspace-local private task directory.' });
|
|
67
|
+
}
|
|
68
|
+
current = parent;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export async function ensureContinuityBoundary(root, path) {
|
|
72
|
+
if (!isPathInside(root, path)) {
|
|
73
|
+
return failed(diagnosticCodes.document_path_unsafe, `Continuity path escapes workspace: ${path}`, {
|
|
74
|
+
nextAction: 'Use only the private task directory and current workspace.',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const realRoot = await resolveRealpath(root);
|
|
78
|
+
if (realRoot === null) {
|
|
79
|
+
return failed(diagnosticCodes.document_path_unsafe, `Cannot resolve workspace root: ${root}`, { nextAction: 'Use an existing readable workspace root.' });
|
|
80
|
+
}
|
|
81
|
+
const current = await resolveRealpath(path);
|
|
82
|
+
if (current !== null) {
|
|
83
|
+
return isPathInside(realRoot, current)
|
|
84
|
+
? succeeded(null)
|
|
85
|
+
: failed(diagnosticCodes.document_path_unsafe, `Continuity path resolves outside workspace: ${path}`, { nextAction: 'Inspect symlinked state paths; no write was made.' });
|
|
86
|
+
}
|
|
87
|
+
return checkCanonicalAncestor(realRoot, path, path);
|
|
88
|
+
}
|
|
89
|
+
export async function ensurePrivateTaskBoundary(root, taskId, path) {
|
|
90
|
+
const paths = continuityTaskPaths(root, taskId);
|
|
91
|
+
if (!isPathInside(paths.taskDir, path)) {
|
|
92
|
+
return failed(diagnosticCodes.document_path_unsafe, `Continuity path is outside the private task directory: ${path}`, {
|
|
93
|
+
nextAction: 'Use only .pappra/local/tasks/<taskId> for checkpoints, pointers and patches.',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const realRoot = await resolveRealpath(root);
|
|
97
|
+
if (realRoot === null) {
|
|
98
|
+
return failed(diagnosticCodes.document_path_unsafe, `Cannot resolve workspace root: ${root}`, { nextAction: 'Use an existing readable workspace root.' });
|
|
99
|
+
}
|
|
100
|
+
const canonicalTask = join(realRoot, '.pappra', 'local', 'tasks', taskId);
|
|
101
|
+
return checkCanonicalAncestor(realRoot, canonicalTask, path);
|
|
102
|
+
}
|
|
103
|
+
async function writeTemporary(path, bytes) {
|
|
104
|
+
const temporary = `${path}.tmp-${randomUUID()}`;
|
|
105
|
+
try {
|
|
106
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
107
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
108
|
+
try {
|
|
109
|
+
await handle.writeFile(bytes);
|
|
110
|
+
await handle.sync();
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
await handle.close().catch(() => undefined);
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, temporary };
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
message: `Cannot write continuity file ${path}: ${errorMessage(error)}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function errorCode(error) {
|
|
126
|
+
return typeof error === 'object' &&
|
|
127
|
+
error !== null &&
|
|
128
|
+
'code' in error &&
|
|
129
|
+
typeof error.code === 'string'
|
|
130
|
+
? error.code
|
|
131
|
+
: null;
|
|
132
|
+
}
|
|
133
|
+
export async function writeContinuityBytes(root, taskId, path, bytes, exclusive, options = {}) {
|
|
134
|
+
const boundary = await ensurePrivateTaskBoundary(root, taskId, path);
|
|
135
|
+
if (!boundary.ok)
|
|
136
|
+
return boundary;
|
|
137
|
+
const temporary = await writeTemporary(path, bytes);
|
|
138
|
+
if (!temporary.ok) {
|
|
139
|
+
return failed(diagnosticCodes.fileUnreadable, temporary.message, {
|
|
140
|
+
nextAction: 'Inspect permissions and available disk space; no success was claimed.',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
if (exclusive) {
|
|
145
|
+
await options.beforeExclusivePublish?.();
|
|
146
|
+
// link(2) publishes atomically and fails with EEXIST if any target,
|
|
147
|
+
// including a concurrently created one, already exists. A precheck
|
|
148
|
+
// followed by rename would replace that target.
|
|
149
|
+
try {
|
|
150
|
+
await link(temporary.temporary, path);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (errorCode(error) === 'EEXIST') {
|
|
154
|
+
return failed(diagnosticCodes.document_write_conflict, `Continuity target already exists: ${path}`, {
|
|
155
|
+
nextAction: 'Use a fresh checkpoint revision; existing state is never replaced.',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return failed(diagnosticCodes.fileUnreadable, `Cannot publish exclusive continuity file ${path}: ${errorMessage(error)}`, {
|
|
159
|
+
nextAction: 'Use a filesystem that supports atomic no-replace publication; no fallback replaced the target.',
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return succeeded({ digest: sha256Digest(bytes) });
|
|
163
|
+
}
|
|
164
|
+
await rename(temporary.temporary, path);
|
|
165
|
+
return succeeded({ digest: sha256Digest(bytes) });
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
return failed(diagnosticCodes.fileUnreadable, `Cannot commit continuity file ${path}: ${errorMessage(error)}`, { nextAction: 'Inspect permissions; no success was claimed.' });
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
await rm(temporary.temporary, { force: true }).catch(() => undefined);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Replaces a file only if its current bytes still have the expected digest.
|
|
176
|
+
* The comparison happens after the temporary bytes are durable and immediately
|
|
177
|
+
* before rename; it is cooperative CAS, not a filesystem-wide transaction.
|
|
178
|
+
*/
|
|
179
|
+
export async function replaceContinuityBytesIfDigest(root, taskId, path, bytes, expectedDigest) {
|
|
180
|
+
const boundary = await ensurePrivateTaskBoundary(root, taskId, path);
|
|
181
|
+
if (!boundary.ok)
|
|
182
|
+
return boundary;
|
|
183
|
+
const temporary = await writeTemporary(path, bytes);
|
|
184
|
+
if (!temporary.ok) {
|
|
185
|
+
return failed(diagnosticCodes.fileUnreadable, temporary.message, {
|
|
186
|
+
nextAction: 'Inspect permissions and available disk space; no success was claimed.',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const current = await readFileBounded(path, CONTINUITY_PAYLOAD_MAX_BYTES);
|
|
191
|
+
const currentDigest = current.ok ? current.value.digest : null;
|
|
192
|
+
if ((!current.ok && current.code !== diagnosticCodes.fileNotFound) ||
|
|
193
|
+
currentDigest !== expectedDigest) {
|
|
194
|
+
return failed(current.ok || current.code === diagnosticCodes.fileNotFound
|
|
195
|
+
? diagnosticCodes.document_proposal_stale
|
|
196
|
+
: current.code, `Continuity file changed before commit: ${path}`, {
|
|
197
|
+
nextAction: 'Reload the pointer and retry explicitly; concurrent bytes were preserved.',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
await rename(temporary.temporary, path);
|
|
201
|
+
return succeeded({ digest: sha256Digest(bytes) });
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
return failed(diagnosticCodes.fileUnreadable, `Cannot commit continuity file ${path}: ${errorMessage(error)}`, { nextAction: 'Inspect permissions; no success was claimed.' });
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
await rm(temporary.temporary, { force: true }).catch(() => undefined);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
export async function readContinuityText(root, taskId, path) {
|
|
211
|
+
const boundary = await ensurePrivateTaskBoundary(root, taskId, path);
|
|
212
|
+
if (!boundary.ok)
|
|
213
|
+
return boundary;
|
|
214
|
+
const read = await readTextFileBounded(path);
|
|
215
|
+
return read.ok ? succeeded(read.value.text) : read;
|
|
216
|
+
}
|
|
217
|
+
export async function readContinuityBytes(root, taskId, path) {
|
|
218
|
+
const boundary = await ensurePrivateTaskBoundary(root, taskId, path);
|
|
219
|
+
if (!boundary.ok)
|
|
220
|
+
return boundary;
|
|
221
|
+
const read = await readFileBounded(path, CONTINUITY_PAYLOAD_MAX_BYTES);
|
|
222
|
+
return read.ok ? succeeded(read.value.bytes) : read;
|
|
223
|
+
}
|
|
224
|
+
export async function removeContinuityFile(root, taskId, path) {
|
|
225
|
+
const boundary = await ensurePrivateTaskBoundary(root, taskId, path);
|
|
226
|
+
if (!boundary.ok)
|
|
227
|
+
return;
|
|
228
|
+
await unlink(path).catch(() => undefined);
|
|
229
|
+
}
|
|
230
|
+
export async function readArtifactPath(root, artifactPath) {
|
|
231
|
+
let absolute;
|
|
232
|
+
try {
|
|
233
|
+
absolute = artifactPath.startsWith('/')
|
|
234
|
+
? artifactPath
|
|
235
|
+
: resolvePortableRelativePath(root, artifactPath);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return failed(diagnosticCodes.document_path_unsafe, `Invalid artifact path: ${artifactPath}`, { nextAction: 'Use a workspace-relative artifact path.' });
|
|
239
|
+
}
|
|
240
|
+
if (!isPathInside(root, absolute)) {
|
|
241
|
+
return failed(diagnosticCodes.document_path_unsafe, `Artifact path escapes workspace: ${artifactPath}`, { nextAction: 'Keep checkpoint artifacts inside the workspace.' });
|
|
242
|
+
}
|
|
243
|
+
const boundary = await ensureContinuityBoundary(root, absolute);
|
|
244
|
+
if (!boundary.ok)
|
|
245
|
+
return boundary;
|
|
246
|
+
const resolution = await resolveWorkspaceRoot({
|
|
247
|
+
startPath: dirname(absolute),
|
|
248
|
+
});
|
|
249
|
+
const realRoot = await resolveRealpath(root);
|
|
250
|
+
const realNested = resolution.root === null ? null : await resolveRealpath(resolution.root);
|
|
251
|
+
if (resolution.state === 'registered' &&
|
|
252
|
+
realNested !== null &&
|
|
253
|
+
realRoot !== null &&
|
|
254
|
+
!samePath(realNested, realRoot)) {
|
|
255
|
+
return failed(diagnosticCodes.document_nested_workspace, `Artifact is inside nested workspace ${resolution.root}: ${artifactPath}`, { nextAction: 'Use an artifact from the current workspace.' });
|
|
256
|
+
}
|
|
257
|
+
return succeeded(absolute);
|
|
258
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Diagnostic } from '../schemas/diagnostics.js';
|
|
2
|
+
import { type Checkpoint, type ResumePacket } from '../schemas/continuity.js';
|
|
3
|
+
export interface NativeRefInput {
|
|
4
|
+
readonly harness: 'pi' | 'codex' | 'claude' | 'unknown';
|
|
5
|
+
readonly sessionId?: string | null;
|
|
6
|
+
readonly turnId?: string | null;
|
|
7
|
+
readonly runId?: string | null;
|
|
8
|
+
}
|
|
9
|
+
export interface ValidationInput {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly result: 'passed' | 'failed' | 'not-run' | 'unknown';
|
|
12
|
+
readonly inputVersion?: string | null;
|
|
13
|
+
}
|
|
14
|
+
export interface SaveCheckpointInput {
|
|
15
|
+
readonly taskId: string;
|
|
16
|
+
readonly objective?: string;
|
|
17
|
+
readonly acceptance?: readonly string[];
|
|
18
|
+
readonly constraints?: readonly string[];
|
|
19
|
+
readonly artifacts?: readonly string[];
|
|
20
|
+
readonly collaborationRecordRef?: string | null;
|
|
21
|
+
readonly decisions?: readonly string[];
|
|
22
|
+
readonly attempted?: readonly string[];
|
|
23
|
+
readonly nextActions?: readonly string[];
|
|
24
|
+
readonly limitations?: readonly string[];
|
|
25
|
+
readonly contextCompleteness?: 'complete' | 'partial';
|
|
26
|
+
readonly authorSession?: string | null;
|
|
27
|
+
readonly nativeRefs?: readonly NativeRefInput[];
|
|
28
|
+
readonly validations?: readonly ValidationInput[];
|
|
29
|
+
}
|
|
30
|
+
export interface SaveCheckpointResult {
|
|
31
|
+
readonly ok: boolean;
|
|
32
|
+
readonly status: 'saved' | 'partial' | 'conflict' | 'failed';
|
|
33
|
+
readonly checkpoint: Checkpoint | null;
|
|
34
|
+
readonly checkpointPath: string | null;
|
|
35
|
+
readonly latestPath: string | null;
|
|
36
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
37
|
+
readonly residualPaths: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
export interface ResumeCheckpointInput {
|
|
40
|
+
readonly taskId: string;
|
|
41
|
+
readonly currentSession?: string | null;
|
|
42
|
+
}
|
|
43
|
+
export interface ResumeCheckpointResult {
|
|
44
|
+
readonly ok: boolean;
|
|
45
|
+
readonly status: 'ready' | 'blocked' | 'not-found' | 'invalid' | 'failed';
|
|
46
|
+
readonly packet: ResumePacket | null;
|
|
47
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
48
|
+
}
|
|
49
|
+
export interface ContinuityServiceOptions {
|
|
50
|
+
/** Test seam: runs after checkpoint bytes are durable, before latest pointer. */
|
|
51
|
+
readonly beforeLatestPointerPersist?: () => Promise<void> | void;
|
|
52
|
+
}
|
|
53
|
+
export declare class CheckpointService {
|
|
54
|
+
#private;
|
|
55
|
+
readonly root: string;
|
|
56
|
+
constructor(root: string, options?: ContinuityServiceOptions);
|
|
57
|
+
save(input: SaveCheckpointInput): Promise<SaveCheckpointResult>;
|
|
58
|
+
resume(input: ResumeCheckpointInput): Promise<ResumeCheckpointResult>;
|
|
59
|
+
}
|