mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
feat(workspaces): verify seeds and add managed repair
Replace binary readiness markers with an evidence-bearing seed manifest and enforce a shared validation contract for minimal and full clones. Add an exclusive audited database repair path with recoverable backup, bounded phase diagnostics, and healthy restart validation. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4b968d8c05
commit
53a0aa3020
21 files changed
+1568
-154
No files matched your search
@@ -137,7 +137,10 @@ test("uses the base CLI when its import graph boots", () => {
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const config = readWorktreeConfig(worktreeCwd);
|
||||
assert.equal(config.$meta.source, "fake-cli");
|
||||
assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending")));
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state,
|
||||
"pending",
|
||||
);
|
||||
const initInvocation = readCliInvocations(baseCwd).find(
|
||||
(args) => args[0] === "worktree" && args[1] === "init",
|
||||
);
|
||||
@@ -166,7 +169,10 @@ test("falls back to an isolated config when the base CLI cannot boot", () => {
|
||||
);
|
||||
const env = fs.readFileSync(path.join(worktreeCwd, ".paperclip", ".env"), "utf8");
|
||||
assert.match(env, /PAPERCLIP_IN_WORKTREE=true/);
|
||||
assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending")));
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state,
|
||||
"pending",
|
||||
);
|
||||
});
|
||||
|
||||
test("repairs an unhealthy base install under the lock and then uses the CLI", (t) => {
|
||||
@@ -271,7 +277,7 @@ test("runtime provisioning invokes ensure-seeded once and fast-exits after succe
|
||||
|
||||
const second = runRuntimeProvision(baseCwd, worktreeCwd);
|
||||
assert.equal(second.status, 0, second.stderr);
|
||||
assert.match(second.stderr, /already seeded; skipping/);
|
||||
assert.match(second.stderr, /already seeded.*skipping/);
|
||||
const ensureCallsAfterSecond = readCliInvocations(baseCwd)
|
||||
.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded");
|
||||
assert.equal(ensureCallsAfterSecond.length, 1);
|
||||
@@ -290,3 +296,22 @@ test("runtime provisioning leaves seed-pending in place when ensure-seeded fails
|
||||
assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending")));
|
||||
assert.ok(!fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-complete")));
|
||||
});
|
||||
|
||||
test("runtime provisioning does not trust a truncated verified manifest", () => {
|
||||
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0, ensureExit: 4 });
|
||||
const worktreeCwd = makeTempDir("paperclip-provision-runtime-truncated-");
|
||||
fs.mkdirSync(path.join(worktreeCwd, ".paperclip"), { recursive: true });
|
||||
fs.writeFileSync(path.join(worktreeCwd, ".paperclip", "config.json"), "{}\n");
|
||||
fs.writeFileSync(
|
||||
path.join(worktreeCwd, ".paperclip", "seed-manifest.json"),
|
||||
JSON.stringify({ version: 2, state: "verified" }),
|
||||
);
|
||||
|
||||
const result = runRuntimeProvision(baseCwd, worktreeCwd);
|
||||
assert.equal(result.status, 4, result.stderr);
|
||||
assert.equal(
|
||||
readCliInvocations(baseCwd)
|
||||
.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded").length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ paperclip_home="${PAPERCLIP_HOME:-$HOME/.paperclip}"
|
||||
paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}"
|
||||
paperclip_dir="$worktree_cwd/.paperclip"
|
||||
worktree_config_path="$paperclip_dir/config.json"
|
||||
seed_manifest_path="$paperclip_dir/seed-manifest.json"
|
||||
seed_pending_marker_path="$paperclip_dir/seed-pending"
|
||||
seed_complete_marker_path="$paperclip_dir/seed-complete"
|
||||
|
||||
@@ -20,8 +21,37 @@ if [[ ! -d "$worktree_cwd" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -e "$seed_complete_marker_path" || ! -e "$seed_pending_marker_path" ]]; then
|
||||
echo "Worktree database is already seeded; skipping runtime provisioning." >&2
|
||||
if [[ -e "$seed_manifest_path" ]]; then
|
||||
seed_manifest_state="$(SEED_MANIFEST_PATH="$seed_manifest_path" node <<'EOF'
|
||||
const fs = require("node:fs");
|
||||
try {
|
||||
const value = JSON.parse(fs.readFileSync(process.env.SEED_MANIFEST_PATH, "utf8"));
|
||||
const complete = value?.version === 2
|
||||
&& value?.state === "verified"
|
||||
&& value?.phase === "complete"
|
||||
&& typeof value?.source?.instanceId === "string" && value.source.instanceId.length > 0
|
||||
&& typeof value?.source?.configPath === "string" && value.source.configPath.length > 0
|
||||
&& (value?.seedMode === "minimal" || value?.seedMode === "full")
|
||||
&& typeof value?.snapshotAt === "string" && value.snapshotAt.length > 0
|
||||
&& typeof value?.migrationRevision === "string" && value.migrationRevision.length > 0
|
||||
&& typeof value?.targetInstanceId === "string" && value.targetInstanceId.length > 0
|
||||
&& typeof value?.attemptId === "string" && value.attemptId.length > 0
|
||||
&& typeof value?.startedAt === "string"
|
||||
&& typeof value?.finishedAt === "string"
|
||||
&& Array.isArray(value?.diagnostics)
|
||||
&& value.diagnostics.some((entry) => entry?.phase === "complete" && entry?.status === "succeeded" && typeof entry?.at === "string");
|
||||
process.stdout.write(complete ? "verified" : "incomplete");
|
||||
} catch {
|
||||
process.stdout.write("invalid");
|
||||
}
|
||||
EOF
|
||||
)"
|
||||
if [[ "$seed_manifest_state" == "verified" ]]; then
|
||||
echo "Worktree database has a verified seed manifest; skipping runtime provisioning." >&2
|
||||
exit 0
|
||||
fi
|
||||
elif [[ -e "$seed_complete_marker_path" || ! -e "$seed_pending_marker_path" ]]; then
|
||||
echo "Worktree database is already seeded by a legacy marker; skipping runtime provisioning." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -31,6 +61,17 @@ if [[ ! -f "$worktree_config_path" ]]; then
|
||||
fi
|
||||
|
||||
source_config_path="${PAPERCLIP_CONFIG:-}"
|
||||
if [[ -e "$seed_manifest_path" ]]; then
|
||||
manifest_source_config_path="$(SEED_MANIFEST_PATH="$seed_manifest_path" node <<'EOF'
|
||||
const fs = require("node:fs");
|
||||
const value = JSON.parse(fs.readFileSync(process.env.SEED_MANIFEST_PATH, "utf8"));
|
||||
process.stdout.write(typeof value?.source?.configPath === "string" ? value.source.configPath : "");
|
||||
EOF
|
||||
)"
|
||||
if [[ -n "$manifest_source_config_path" ]]; then
|
||||
source_config_path="$manifest_source_config_path"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$source_config_path" && ( -e "$base_cwd/.paperclip/config.json" || -L "$base_cwd/.paperclip/config.json" ) ]]; then
|
||||
source_config_path="$base_cwd/.paperclip/config.json"
|
||||
fi
|
||||
@@ -41,7 +82,7 @@ source_config_args=(--from-config "$source_config_path")
|
||||
if [[ "$source_config_path" == "$worktree_config_path" ]]; then
|
||||
# A human may invoke this after sourcing `worktree env`, which points
|
||||
# PAPERCLIP_CONFIG at the target. In that case the CLI reads the original
|
||||
# source config from the seed-pending marker instead.
|
||||
# source config from the seed manifest or legacy pending marker instead.
|
||||
source_config_args=()
|
||||
fi
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}"
|
||||
paperclip_dir="$worktree_cwd/.paperclip"
|
||||
worktree_config_path="$paperclip_dir/config.json"
|
||||
worktree_env_path="$paperclip_dir/.env"
|
||||
seed_manifest_path="$paperclip_dir/seed-manifest.json"
|
||||
seed_pending_marker_path="$paperclip_dir/seed-pending"
|
||||
seed_complete_marker_path="$paperclip_dir/seed-complete"
|
||||
worktree_name="${PAPERCLIP_WORKSPACE_BRANCH:-$(basename "$worktree_cwd")}"
|
||||
@@ -237,25 +238,48 @@ for (const rawValue of runtimePaths) {
|
||||
EOF
|
||||
}
|
||||
|
||||
write_seed_pending_marker() {
|
||||
write_seed_pending_manifest() {
|
||||
SEED_MANIFEST_PATH="$seed_manifest_path" \
|
||||
SEED_PENDING_MARKER_PATH="$seed_pending_marker_path" \
|
||||
SEED_COMPLETE_MARKER_PATH="$seed_complete_marker_path" \
|
||||
SOURCE_CONFIG_PATH="$source_config_path" \
|
||||
TARGET_INSTANCE_ID="$worktree_instance_id" \
|
||||
node <<'EOF'
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const manifestPath = process.env.SEED_MANIFEST_PATH;
|
||||
const pendingPath = process.env.SEED_PENDING_MARKER_PATH;
|
||||
const completePath = process.env.SEED_COMPLETE_MARKER_PATH;
|
||||
const sourceConfigPath = path.resolve(process.env.SOURCE_CONFIG_PATH);
|
||||
const sourceEnvPath = path.join(path.dirname(sourceConfigPath), ".env");
|
||||
let sourceInstanceId = path.basename(path.dirname(sourceConfigPath));
|
||||
if (fs.existsSync(sourceEnvPath)) {
|
||||
const match = fs.readFileSync(sourceEnvPath, "utf8").match(/^\s*(?:export\s+)?PAPERCLIP_INSTANCE_ID\s*=\s*["']?([^\s"'#]+)["']?/m);
|
||||
if (match?.[1]) sourceInstanceId = match[1];
|
||||
}
|
||||
fs.rmSync(completePath, { force: true });
|
||||
fs.rmSync(pendingPath, { force: true });
|
||||
const at = new Date().toISOString();
|
||||
fs.writeFileSync(
|
||||
pendingPath,
|
||||
manifestPath,
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
state: "pending",
|
||||
sourceConfigPath: path.resolve(process.env.SOURCE_CONFIG_PATH),
|
||||
version: 2,
|
||||
source: {
|
||||
instanceId: sourceInstanceId,
|
||||
configPath: sourceConfigPath,
|
||||
},
|
||||
snapshotAt: null,
|
||||
seedMode: "minimal",
|
||||
createdAt: new Date().toISOString(),
|
||||
migrationRevision: null,
|
||||
targetInstanceId: process.env.TARGET_INSTANCE_ID,
|
||||
phase: "pending",
|
||||
state: "pending",
|
||||
attemptId: crypto.randomUUID(),
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
diagnostics: [{ phase: "pending", status: "succeeded", at }],
|
||||
}, null, 2)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
@@ -551,8 +575,8 @@ else
|
||||
created_worktree_config=1
|
||||
fi
|
||||
|
||||
if [[ "$created_worktree_config" -eq 1 && ! -e "$seed_pending_marker_path" && ! -e "$seed_complete_marker_path" ]]; then
|
||||
write_seed_pending_marker
|
||||
if [[ "$created_worktree_config" -eq 1 && ! -e "$seed_manifest_path" && ! -e "$seed_pending_marker_path" && ! -e "$seed_complete_marker_path" ]]; then
|
||||
write_seed_pending_manifest
|
||||
fi
|
||||
|
||||
list_base_node_modules_paths() {
|
||||
|
||||
Reference in new issue
Block a user