@wipcomputer/wip-ldm-os 0.4.87-alpha.1 → 0.4.87-alpha.3
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/bin/ldm.js +49 -39
- package/docs/backup/README.md +83 -80
- package/docs/backup/TECHNICAL.md +107 -90
- package/package.json +15 -3
- package/scripts/ldm-backup-engine.py +1176 -0
- package/scripts/ldm-backup-policy.json +1420 -0
- package/scripts/ldm-backup.sh +15 -273
- package/scripts/test-backup-engine.py +609 -0
- package/scripts/test-bin-manifest.mjs +43 -0
- package/scripts/test-ldm-install-preserves-foreign-bin.mjs +5 -0
- package/shared/docs/README.md.tmpl +1 -1
- package/shared/docs/directory-map.md.tmpl +1 -1
- package/shared/docs/how-backup-works.md.tmpl +50 -83
- package/shared/docs/system-directories.md.tmpl +1 -1
- package/shared/docs/what-is-dotldm.md.tmpl +1 -1
- package/src/bridge/openclaw.plugin.json +13 -0
- package/src/bridge/package.json +3 -0
package/bin/ldm.js
CHANGED
|
@@ -724,7 +724,7 @@ async function installCatalogComponent(c) {
|
|
|
724
724
|
}
|
|
725
725
|
|
|
726
726
|
// ── Bridge deploy (#245) ──
|
|
727
|
-
// Deploy
|
|
727
|
+
// Deploy runtime-owned backup and shell files from scripts/ to ~/.ldm/bin/
|
|
728
728
|
// Called from both cmdInit() and cmdInstallCatalog() so script fixes land on every update.
|
|
729
729
|
function deployScripts() {
|
|
730
730
|
const scriptsSrc = join(__dirname, '..', 'scripts');
|
|
@@ -732,15 +732,17 @@ function deployScripts() {
|
|
|
732
732
|
mkdirSync(join(LDM_ROOT, 'bin'), { recursive: true });
|
|
733
733
|
let count = 0;
|
|
734
734
|
for (const file of readdirSync(scriptsSrc)) {
|
|
735
|
-
|
|
735
|
+
const isShell = file.endsWith('.sh');
|
|
736
|
+
const isBackupRuntime = file === 'ldm-backup-engine.py' || file === 'ldm-backup-policy.json';
|
|
737
|
+
if (!isShell && !isBackupRuntime) continue;
|
|
736
738
|
const src = join(scriptsSrc, file);
|
|
737
739
|
const dest = join(LDM_ROOT, 'bin', file);
|
|
738
740
|
cpSync(src, dest);
|
|
739
|
-
chmodSync(dest, 0o755);
|
|
741
|
+
chmodSync(dest, file.endsWith('.json') ? 0o644 : 0o755);
|
|
740
742
|
count++;
|
|
741
743
|
}
|
|
742
744
|
if (count > 0) {
|
|
743
|
-
console.log(` + ${count}
|
|
745
|
+
console.log(` + ${count} runtime file(s) deployed to ~/.ldm/bin/`);
|
|
744
746
|
}
|
|
745
747
|
return count;
|
|
746
748
|
}
|
|
@@ -964,6 +966,8 @@ function deployRules() {
|
|
|
964
966
|
function deployBridge() {
|
|
965
967
|
const ldmBridgeDir = join(LDM_EXTENSIONS, 'lesa-bridge');
|
|
966
968
|
const ocBridgeDir = join(HOME, '.openclaw', 'extensions', 'lesa-bridge');
|
|
969
|
+
const bridgeMetadataDir = join(__dirname, '..', 'src', 'bridge');
|
|
970
|
+
const bridgeMetadataFiles = ['openclaw.plugin.json', 'package.json'];
|
|
967
971
|
|
|
968
972
|
// Deploy targets: LDM path (canonical) and OpenClaw path (where the plugin loads)
|
|
969
973
|
// Create dirs if missing so first-time deploy works (don't skip with filter)
|
|
@@ -1000,51 +1004,52 @@ function deployBridge() {
|
|
|
1000
1004
|
|
|
1001
1005
|
if (!bridgeSrc || !existsSync(bridgeSrc)) return 0;
|
|
1002
1006
|
|
|
1003
|
-
|
|
1004
|
-
const
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
const
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
const
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
if (!srcContent.equals(destContent)) {
|
|
1018
|
-
changed = true;
|
|
1019
|
-
break;
|
|
1007
|
+
const srcFiles = readdirSync(bridgeSrc).filter(f => f.endsWith('.js') || f.endsWith('.d.ts'));
|
|
1008
|
+
const srcFileSet = new Set(srcFiles);
|
|
1009
|
+
|
|
1010
|
+
function targetNeedsDeploy(target) {
|
|
1011
|
+
const checkDest = join(target.dir, 'dist');
|
|
1012
|
+
try {
|
|
1013
|
+
if (!existsSync(checkDest)) return true;
|
|
1014
|
+
for (const file of srcFiles) {
|
|
1015
|
+
const srcPath = join(bridgeSrc, file);
|
|
1016
|
+
const destPath = join(checkDest, file);
|
|
1017
|
+
if (!existsSync(destPath)) return true;
|
|
1018
|
+
const srcContent = readFileSync(srcPath);
|
|
1019
|
+
const destContent = readFileSync(destPath);
|
|
1020
|
+
if (!srcContent.equals(destContent)) return true;
|
|
1020
1021
|
}
|
|
1021
|
-
|
|
1022
|
-
// Also check if there are stale files in the target that aren't in the source
|
|
1023
|
-
if (!changed) {
|
|
1022
|
+
|
|
1024
1023
|
const destFiles = readdirSync(checkDest).filter(f => f.endsWith('.js'));
|
|
1025
|
-
const srcFileSet = new Set(srcFiles);
|
|
1026
1024
|
for (const file of destFiles) {
|
|
1027
|
-
if (!srcFileSet.has(file))
|
|
1028
|
-
changed = true; // stale chunk file found
|
|
1029
|
-
break;
|
|
1030
|
-
}
|
|
1025
|
+
if (!srcFileSet.has(file)) return true;
|
|
1031
1026
|
}
|
|
1027
|
+
|
|
1028
|
+
for (const file of bridgeMetadataFiles) {
|
|
1029
|
+
const srcPath = join(bridgeMetadataDir, file);
|
|
1030
|
+
const destPath = join(target.dir, file);
|
|
1031
|
+
if (!existsSync(srcPath) || !existsSync(destPath)) return true;
|
|
1032
|
+
if (!readFileSync(srcPath).equals(readFileSync(destPath))) return true;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
return false;
|
|
1036
|
+
} catch {
|
|
1037
|
+
return true;
|
|
1032
1038
|
}
|
|
1033
|
-
} catch {
|
|
1034
|
-
changed = true; // if comparison fails, copy anyway
|
|
1035
1039
|
}
|
|
1036
1040
|
|
|
1037
|
-
|
|
1041
|
+
const staleTargets = targets.filter(targetNeedsDeploy);
|
|
1042
|
+
if (staleTargets.length === 0) return 0;
|
|
1038
1043
|
|
|
1039
1044
|
if (DRY_RUN) {
|
|
1040
|
-
console.log(` + would deploy bridge files to ${
|
|
1045
|
+
console.log(` + would deploy bridge files to ${staleTargets.map(t => t.label).join(' + ')}`);
|
|
1041
1046
|
return 0;
|
|
1042
1047
|
}
|
|
1043
1048
|
|
|
1044
|
-
const srcFiles = readdirSync(bridgeSrc).filter(f => f.endsWith('.js') || f.endsWith('.d.ts'));
|
|
1045
1049
|
let totalDeployed = 0;
|
|
1050
|
+
let ldmTargetDeployed = false;
|
|
1046
1051
|
|
|
1047
|
-
for (const target of
|
|
1052
|
+
for (const target of staleTargets) {
|
|
1048
1053
|
const dest = join(target.dir, 'dist');
|
|
1049
1054
|
try {
|
|
1050
1055
|
mkdirSync(dest, { recursive: true });
|
|
@@ -1063,16 +1068,21 @@ function deployBridge() {
|
|
|
1063
1068
|
for (const file of srcFiles) {
|
|
1064
1069
|
cpSync(join(bridgeSrc, file), join(dest, file));
|
|
1065
1070
|
}
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1071
|
+
for (const file of bridgeMetadataFiles) {
|
|
1072
|
+
cpSync(join(bridgeMetadataDir, file), join(target.dir, file));
|
|
1073
|
+
}
|
|
1074
|
+
const deployedCount = srcFiles.length + bridgeMetadataFiles.length;
|
|
1075
|
+
console.log(` + bridge deployed to ${target.label} (${deployedCount} files)`);
|
|
1076
|
+
installLog(`Bridge deployed: ${deployedCount} files to ${target.label}`);
|
|
1077
|
+
totalDeployed += deployedCount;
|
|
1078
|
+
if (target.dir === ldmBridgeDir) ldmTargetDeployed = true;
|
|
1069
1079
|
} catch (e) {
|
|
1070
1080
|
console.log(` ! bridge deploy to ${target.label} failed: ${e.message}`);
|
|
1071
1081
|
}
|
|
1072
1082
|
}
|
|
1073
1083
|
|
|
1074
1084
|
// Re-register MCP server to point to the canonical LDM path
|
|
1075
|
-
if (
|
|
1085
|
+
if (ldmTargetDeployed && existsSync(join(ldmBridgeDir, 'dist', 'mcp-server.js'))) {
|
|
1076
1086
|
try {
|
|
1077
1087
|
const mcpPath = join(ldmBridgeDir, 'dist', 'mcp-server.js');
|
|
1078
1088
|
execSync(`claude mcp add lesa-bridge --scope user -- node ${mcpPath}`, {
|
package/docs/backup/README.md
CHANGED
|
@@ -1,108 +1,111 @@
|
|
|
1
1
|
# Backup
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
LDM OS uses one policy-driven backup engine. The scheduled job remains separately controlled by LaunchAgent `ai.openclaw.ldm-backup`.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Safe dry run
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Run this after the repaired engine is released and installed:
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
| `~/.ldm/agents/` | cp -a | Identity files, journals, daily logs |
|
|
13
|
-
| `~/.ldm/state/` | cp -a | Config, version, registry |
|
|
14
|
-
| `~/.ldm/config.json` | cp | Workspace pointer, org |
|
|
15
|
-
| `~/.openclaw/memory/main.sqlite` | sqlite3 .backup | OC conversations |
|
|
16
|
-
| `~/.openclaw/memory/context-embeddings.sqlite` | sqlite3 .backup | Embeddings |
|
|
17
|
-
| `~/.openclaw/workspace/` | tar | Shared context, daily logs |
|
|
18
|
-
| `~/.openclaw/agents/main/sessions/` | tar | OC session JSONL |
|
|
19
|
-
| `~/.openclaw/openclaw.json` | cp | OC config |
|
|
20
|
-
| `~/.claude/CLAUDE.md` | cp | CC instructions |
|
|
21
|
-
| `~/.claude/settings.json` | cp | CC settings |
|
|
22
|
-
| `~/.claude/projects/` | tar | CC auto-memory + transcripts |
|
|
23
|
-
| Workspace directory | tar (excludes node_modules, .git/objects, old backups, _trash) | Entire workspace |
|
|
9
|
+
```bash
|
|
10
|
+
~/.ldm/bin/ldm-backup.sh --dry-run
|
|
11
|
+
```
|
|
24
12
|
|
|
25
|
-
|
|
13
|
+
The dry run reads the same canonical policy and estimator as a real run. It reports every source classification, exclusions and reasons, per-source estimates, total estimated new bytes, disk capacity, available bytes, required reserve, planned staging and destination paths, blocked requirements, and the preflight verdict.
|
|
26
14
|
|
|
27
|
-
|
|
15
|
+
Dry run is strictly read-only. It does not create a directory, acquire a persistent lock, write structured state, create a manifest or completion marker, copy data, rotate snapshots, access Vault or iCloud for writing, change the schedule, enable the LaunchAgent, or modify containment state. Sensitive sources use policy display labels. Their filenames and contents are not enumerated.
|
|
28
16
|
|
|
29
|
-
|
|
30
|
-
~/.ldm/backups/2026-03-24--09-50-22/
|
|
31
|
-
ldm/
|
|
32
|
-
memory/crystal.db
|
|
33
|
-
agents/
|
|
34
|
-
state/
|
|
35
|
-
config.json
|
|
36
|
-
openclaw/
|
|
37
|
-
memory/main.sqlite
|
|
38
|
-
memory/context-embeddings.sqlite
|
|
39
|
-
workspace.tar
|
|
40
|
-
sessions.tar
|
|
41
|
-
openclaw.json
|
|
42
|
-
claude/
|
|
43
|
-
CLAUDE.md
|
|
44
|
-
settings.json
|
|
45
|
-
projects.tar
|
|
46
|
-
<workspace>.tar
|
|
47
|
-
```
|
|
17
|
+
Dry run exits zero only when the same plan would pass real-run preflight. Required missing sources, blocked encryption gates, destination failure, and reserve failure return nonzero.
|
|
48
18
|
|
|
49
|
-
##
|
|
19
|
+
## Canonical policy
|
|
50
20
|
|
|
51
|
-
|
|
21
|
+
The installed `~/.ldm/bin/ldm-backup-policy.json` is the source of truth for:
|
|
52
22
|
|
|
53
|
-
|
|
23
|
+
- Required, rebuildable, sensitive, blocked, manifest-only, and excluded sources.
|
|
24
|
+
- Per-source capture types and destinations.
|
|
25
|
+
- Exact file-selection and exclusion rules.
|
|
26
|
+
- Disk-floor percentages.
|
|
27
|
+
- The two legal evidence states: `complete` and `deferred-after-write-failure`.
|
|
54
28
|
|
|
55
|
-
|
|
29
|
+
Estimation, capture, manifest generation, and exclusion records all consume this policy. Sensitive sources that still require encryption are marked blocked. The engine fails honestly instead of silently omitting them.
|
|
56
30
|
|
|
57
|
-
|
|
58
|
-
~/.ldm/bin/ldm-backup.sh # run backup now
|
|
59
|
-
~/.ldm/bin/ldm-backup.sh --dry-run # preview what would be backed up
|
|
60
|
-
~/.ldm/bin/ldm-backup.sh --keep 14 # keep 14 days instead of 7
|
|
61
|
-
~/.ldm/bin/ldm-backup.sh --include-secrets # include ~/.ldm/secrets/
|
|
62
|
-
```
|
|
31
|
+
The policy explicitly represents every Slice 1 inventory row. The fixture suite asserts the exact source-ID set so deleting or silently omitting a source fails testing.
|
|
63
32
|
|
|
64
|
-
|
|
33
|
+
## Runtime dependency
|
|
65
34
|
|
|
66
|
-
|
|
67
|
-
ldm backup # run backup now
|
|
68
|
-
ldm backup --dry-run # preview with sizes
|
|
69
|
-
ldm backup --pin "before upgrade" # pin latest backup so rotation skips it
|
|
70
|
-
```
|
|
35
|
+
The engine requires Python 3 and uses only the Python standard library. No third-party Python packages are required. The shell entry point checks `command -v python3` first and reports `Backup failed [python3-missing]` when the runtime is unavailable. Parker's operational acceptance of this release prerequisite remains a separate release gate.
|
|
71
36
|
|
|
72
|
-
|
|
37
|
+
Configuration is fail-closed. A missing `~/.ldm/config.json` uses documented defaults, but an unreadable, malformed, or structurally invalid config stops planning with `config-invalid`.
|
|
73
38
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
39
|
+
## Mutation consistency
|
|
40
|
+
|
|
41
|
+
The dry-run estimate is a plan-time observation. A real run freshly observes each source immediately before that source is captured. Changes between planning and capture are allowed, recorded in the manifest through plan-time and capture-time metadata, and captured from the newer observation.
|
|
42
|
+
|
|
43
|
+
Regular files, symlinks, directory membership, and tree metadata must remain stable during their individual source capture. A same-size content change is detected by checksum. Any change during capture is fatal and leaves the snapshot in progress without completion proof. SQLite sources use the SQLite backup API as a transactionally consistent snapshot and are labeled `sqlite-backup-api-transaction-snapshot`.
|
|
44
|
+
|
|
45
|
+
When a source observation changes before capture, the engine recalculates the reserve before copying. The recalculation includes bytes already staged, stat-only refreshed estimates for remaining sources, a full checksum observation of the source about to be copied, projected metadata, and the 10 percent required reserve. Growth that crosses the floor fails with `capture-reserve-gate` before the changed source is copied.
|
|
46
|
+
|
|
47
|
+
## Manifest evidence
|
|
48
|
+
|
|
49
|
+
`manifest` is an evidence-producing action, not a descriptive policy label. Implemented path-inventory generators emit a machine-readable member inventory, logical byte count, and inventory checksum. Every required manifest action must produce evidence that passes completion validation. Entries without an implemented generator are marked blocked before any filesystem walk. Required system and repo-aware Git/worktree manifests whose generators are not implemented remain explicitly blocked.
|
|
50
|
+
|
|
51
|
+
## Successful snapshot contract
|
|
52
|
+
|
|
53
|
+
A real internal-tier run can report success only after:
|
|
54
|
+
|
|
55
|
+
1. The process lock is acquired.
|
|
56
|
+
2. Required sources and policy gates pass.
|
|
57
|
+
3. Free space passes the 10 percent post-write reserve.
|
|
58
|
+
4. Capture completes under `.in-progress-<snapshot-id>`.
|
|
59
|
+
5. SQLite backups pass `PRAGMA quick_check`.
|
|
60
|
+
6. Required manifest actions produce validated evidence.
|
|
61
|
+
7. Every captured file has a manifest checksum.
|
|
62
|
+
8. The manifest validates.
|
|
63
|
+
9. `completion.json` is written last with atomic rename.
|
|
64
|
+
10. The staging directory is atomically promoted.
|
|
65
|
+
|
|
66
|
+
Any required-source or validation failure exits nonzero, writes a fixture-visible failure result when possible, leaves no completion marker, and never prints `Backup complete`.
|
|
67
|
+
|
|
68
|
+
## Snapshot layout
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
~/.ldm/backups/v1/internal/
|
|
72
|
+
.in-progress-2026-07-11--19-00-00Z/
|
|
73
|
+
2026-07-11--19-00-00Z/
|
|
74
|
+
ldm/
|
|
75
|
+
openclaw/
|
|
76
|
+
claude/
|
|
77
|
+
codex/
|
|
78
|
+
manifest.json
|
|
79
|
+
completion.json
|
|
81
80
|
```
|
|
82
81
|
|
|
83
|
-
|
|
82
|
+
Only verified completed snapshots participate in retention. Pinned snapshots never consume an unpinned retention slot. In-progress and unverified directories are never rotated.
|
|
83
|
+
|
|
84
|
+
## Structured state
|
|
85
|
+
|
|
86
|
+
The engine defines atomic records under `~/.ldm/state/backup/v1/`:
|
|
84
87
|
|
|
85
|
-
|
|
88
|
+
- `schedule.json`
|
|
89
|
+
- `attempt.json`
|
|
90
|
+
- `result.json`
|
|
91
|
+
- `success-local.json`
|
|
86
92
|
|
|
87
|
-
|
|
88
|
-
|------|------|-----|
|
|
89
|
-
| Backup | 3:00 AM | LaunchAgent `ai.openclaw.ldm-backup` |
|
|
93
|
+
Vault and iCloud success records are reserved for their later slices. State is diagnostic. Snapshot proof requires the manifest and completion marker.
|
|
90
94
|
|
|
91
|
-
|
|
95
|
+
Snapshot identifiers use UTC and end in `Z`, which prevents local DST fall-back collisions. When no valid containment marker exists, `schedule.json` records `evidence_status: unknown`; it never invents a deferred write failure.
|
|
92
96
|
|
|
93
|
-
##
|
|
97
|
+
## Fixture suite
|
|
94
98
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
- `org` ... used for tar filename prefix
|
|
99
|
+
The complete engine suite writes only beneath unique disposable fixture roots:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npm run test:backup-engine
|
|
103
|
+
```
|
|
101
104
|
|
|
102
|
-
|
|
105
|
+
The suite injects home, LDM, OpenClaw, Claude, Codex, workspace, destination, state, iCloud substitute, and lock paths. It aborts if any path escapes the fixture root.
|
|
103
106
|
|
|
104
|
-
|
|
107
|
+
## Current tier boundary
|
|
105
108
|
|
|
106
|
-
|
|
109
|
+
This Slice 2 engine implements internal-tier correctness only. Vault, iCloud, monitoring, restore, migration, and live schedule resumption remain separately gated. Do not run a real backup until those gates are approved.
|
|
107
110
|
|
|
108
|
-
See [TECHNICAL.md](./TECHNICAL.md) for
|
|
111
|
+
See [TECHNICAL.md](./TECHNICAL.md) for schemas and engine details.
|
package/docs/backup/TECHNICAL.md
CHANGED
|
@@ -1,112 +1,129 @@
|
|
|
1
1
|
# Backup: Technical Details
|
|
2
2
|
|
|
3
|
-
##
|
|
4
|
-
|
|
5
|
-
All backup settings are in `~/.ldm/config.json`. The backup script reads these at runtime.
|
|
6
|
-
|
|
7
|
-
```json
|
|
8
|
-
{
|
|
9
|
-
"org": "wipcomputerinc",
|
|
10
|
-
"paths": {
|
|
11
|
-
"workspace": "~/wipcomputerinc",
|
|
12
|
-
"ldm": "~/.ldm",
|
|
13
|
-
"claude": "~/.claude",
|
|
14
|
-
"openclaw": "~/.openclaw",
|
|
15
|
-
"icloudBackup": "~/Library/Mobile Documents/com~apple~CloudDocs/wipcomputerinc-icloud/backups"
|
|
16
|
-
},
|
|
17
|
-
"backup": {
|
|
18
|
-
"keep": 7,
|
|
19
|
-
"includeSecrets": false
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
```
|
|
3
|
+
## Installed components
|
|
23
4
|
|
|
24
|
-
|
|
|
25
|
-
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
28
|
-
| `backup.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
</dict>
|
|
55
|
-
```
|
|
5
|
+
| Source | Installed path | Purpose |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `scripts/ldm-backup.sh` | `~/.ldm/bin/ldm-backup.sh` | Stable shell entry point |
|
|
8
|
+
| `scripts/ldm-backup-engine.py` | `~/.ldm/bin/ldm-backup-engine.py` | Policy, planning, capture, validation, state, and retention engine |
|
|
9
|
+
| `scripts/ldm-backup-policy.json` | `~/.ldm/bin/ldm-backup-policy.json` | Canonical source and exclusion policy |
|
|
10
|
+
|
|
11
|
+
The package bin manifest owns all three files. `ldm install` deploys code only. It does not run a backup, create a destination, change the LaunchAgent, enable a schedule, or write a containment marker.
|
|
12
|
+
|
|
13
|
+
The engine runtime is Python 3 standard library only. The shell entry point checks for `python3` and emits the structured failure code `python3-missing` before execution when it is unavailable. No pip package or third-party Python dependency is introduced.
|
|
14
|
+
|
|
15
|
+
## Canonical policy schema
|
|
16
|
+
|
|
17
|
+
Policy schema version 1 contains:
|
|
18
|
+
|
|
19
|
+
- `rule_version`
|
|
20
|
+
- Explicit `evidence_status_values`
|
|
21
|
+
- Disk floors and start reserve
|
|
22
|
+
- Source ID, root, relative path, safe display path, destination, type, classification, required flag, and action
|
|
23
|
+
- Block reason when encryption or reconstruction proof is missing
|
|
24
|
+
- Exclusion pattern, reason, reconstruction source, and rule version
|
|
25
|
+
|
|
26
|
+
Legal actions are `capture`, `blocked`, `manifest`, and `exclude`. Legal types are `file`, `tree`, and `sqlite`. The placeholder string `complete-or-deferred-after-write-failure` is rejected by policy and runtime state validation.
|
|
27
|
+
|
|
28
|
+
The production policy has an explicit ID for every controlling Slice 1 source row. A fixture asserts exact set equality, not subset membership, so either an omission or an unreviewed addition fails loudly.
|
|
29
|
+
|
|
30
|
+
`manifest` requires a named evidence generator. The implemented `path-inventory` generator records the source type, exact members, member count, logical bytes, and SHA-256 of the serialized inventory. A missing source, generator failure, malformed record, or missing required evidence prevents completion. Required evidence without a complete Slice 2 generator uses `blocked`, including repo-aware Git refs and worktree state, macOS system facts, package inventories, and out-of-band 1Password bootstrap proof.
|
|
31
|
+
|
|
32
|
+
## Planning and dry run
|
|
33
|
+
|
|
34
|
+
Planning resolves the configured roots, expands each policy entry, selects files using its exclusion rules, and computes checksums and logical sizes. `estimated_new_bytes` is the exact logical payload plus the serialized manifest and completion marker for an unchanged source set. The disk reserve is evaluated against that plan-time value.
|
|
56
35
|
|
|
57
|
-
|
|
36
|
+
`--dry-run` stops after planning and preflight. It does not acquire the persistent lock or call any atomic writer. JSON output is available with `--dry-run --json` for reviewers and tests.
|
|
58
37
|
|
|
59
|
-
|
|
38
|
+
Configuration loading is fail-closed. A missing config permits documented defaults. An unreadable file, malformed JSON, a non-object root, or non-object `paths` or `backup` field produces `config-invalid`.
|
|
60
39
|
|
|
61
|
-
|
|
40
|
+
## Mutation-during-run contract
|
|
62
41
|
|
|
63
|
-
|
|
42
|
+
Planning is not treated as a filesystem freeze. Immediately before each source capture, the engine rebuilds that source observation from the same canonical policy. Plan-to-capture changes are allowed and recorded through planned size, modification time, checksum, capture-time size, capture-time modification time, destination checksum, and `changed_since_plan`.
|
|
64
43
|
|
|
65
|
-
|
|
66
|
-
- Old cron entries referencing `LDMDevTools.app`
|
|
67
|
-
- `com.wipcomputer.daily-backup` LaunchAgent
|
|
68
|
-
- OpenClaw `backup-verify` cron entries
|
|
44
|
+
For regular files and trees, the capture-time observation is the source correspondence proof. Destination bytes are compared to that observation, and the manifest records per-source member counts, payload bytes, and inventory fingerprints for both planning and capture. After copying the full source, the engine observes it again. Any member addition, removal, type change, size change, modification-time change, or checksum change during capture fails with `source-changed-during-capture`. Same-size content changes are therefore detected. A plan-time change does not force failure merely because an active source changed before its capture began.
|
|
69
45
|
|
|
70
|
-
|
|
46
|
+
SQLite uses a separate contract. Python's SQLite backup API produces a transactionally consistent destination snapshot. The manifest labels these records `sqlite-backup-api-transaction-snapshot`; it does not claim that the destination checksum equals a raw live database file checksum.
|
|
71
47
|
|
|
72
|
-
|
|
48
|
+
Before copying each source, the engine refreshes size and modification-time estimates for every capture source still pending without hashing each remaining tree. It then performs the full member and checksum observation for the source about to be copied. The engine recalculates the reserve from bytes already staged, refreshed remaining payload, projected manifest and completion metadata, current available space, and the required reserve. Metadata projection updates only changed source and evidence records instead of rebuilding the complete manifest for every source. A failed recheck raises `capture-reserve-gate` before the changed source is copied and records the updated estimate and reserve in `result.json`.
|
|
73
49
|
|
|
74
|
-
|
|
50
|
+
Manifest actions are gated before any source path is inspected or walked. An entry without an implemented generator is reported as blocked immediately. This prevents generator-less entries from hashing large trees or failing on special files that the unused source path happens to contain.
|
|
75
51
|
|
|
76
|
-
|
|
77
|
-
2. Sort by name (which sorts chronologically)
|
|
78
|
-
3. Skip any directory containing a `.pinned` marker file
|
|
79
|
-
4. Delete directories beyond the `keep` count (oldest first)
|
|
80
|
-
5. Same rotation logic applies to iCloud tars at `paths.icloudBackup`
|
|
52
|
+
## Run lifecycle
|
|
81
53
|
|
|
82
|
-
|
|
54
|
+
1. Create the lock with `O_CREAT | O_EXCL`.
|
|
55
|
+
2. Reject an active lock. Report a dead-PID lock as stale without replacing it.
|
|
56
|
+
3. Build the plan from the canonical policy.
|
|
57
|
+
4. Atomically write `schedule.json` and `attempt.json` before staging.
|
|
58
|
+
5. Fail preflight before any staging directory when a required gate, destination, or reserve check fails.
|
|
59
|
+
6. Create `.in-progress-<snapshot-id>`.
|
|
60
|
+
7. Generate and validate required manifest evidence.
|
|
61
|
+
8. Re-observe each capture source and all remaining capture sources.
|
|
62
|
+
9. Recalculate the disk reserve before copying the current source.
|
|
63
|
+
10. Capture SQLite databases with Python's SQLite backup API and run `PRAGMA quick_check` on each destination.
|
|
64
|
+
11. Re-observe regular-file and tree sources after capture and fail if they changed during capture.
|
|
65
|
+
12. Atomically write `manifest.json`.
|
|
66
|
+
13. Validate every required capture record and manifest-evidence record.
|
|
67
|
+
14. Atomically write `completion.json` last.
|
|
68
|
+
15. Verify completion proof, then atomically promote staging.
|
|
69
|
+
16. Write `result.json` and `success-local.json`.
|
|
70
|
+
17. Rotate only eligible verified snapshots.
|
|
83
71
|
|
|
84
|
-
|
|
72
|
+
Failures do not promote staging and do not print `Backup complete`. A process crash can leave only an unmistakable in-progress directory and stale lock.
|
|
85
73
|
|
|
86
|
-
|
|
74
|
+
## Manifest fields
|
|
87
75
|
|
|
88
|
-
|
|
89
|
-
2. Filename format: `<org>-<machine>-<timestamp>.tar.gz`
|
|
90
|
-
3. Copy to `paths.icloudBackup` (from config.json)
|
|
91
|
-
4. Apply the same rotation (keep N, skip pinned)
|
|
92
|
-
5. iCloud syncs the file to all devices automatically
|
|
76
|
+
Each captured member records:
|
|
93
77
|
|
|
94
|
-
|
|
78
|
+
- Source ID and resolved source path
|
|
79
|
+
- Relative destination
|
|
80
|
+
- Type
|
|
81
|
+
- Logical size
|
|
82
|
+
- Modification time in nanoseconds
|
|
83
|
+
- SHA-256 checksum
|
|
84
|
+
- Plan-time size, modification time, and checksum where meaningful
|
|
85
|
+
- Whether the source changed between planning and capture
|
|
86
|
+
- The source consistency contract
|
|
95
87
|
|
|
96
|
-
|
|
88
|
+
The manifest also records schema version, UTC snapshot ID, tool version, policy rule version, timestamps, planned and captured payload bytes, required manifest evidence, exclusions, and terminal result. Each exclusion records source ID, path or pattern, reason, reconstruction source, rule version, and matched count.
|
|
97
89
|
|
|
98
|
-
|
|
90
|
+
`completion.json` records snapshot ID, `status: complete`, completion time, and manifest checksum. Its presence alone is insufficient unless the manifest checksum and terminal result validate.
|
|
99
91
|
|
|
100
|
-
|
|
101
|
-
- `~/.ldm/memory/crystal.db`
|
|
102
|
-
- `~/.openclaw/memory/main.sqlite`
|
|
103
|
-
- `~/.openclaw/memory/context-embeddings.sqlite`
|
|
92
|
+
## Structured state
|
|
104
93
|
|
|
105
|
-
|
|
94
|
+
All state files use same-directory temporary files, `fsync`, and atomic rename.
|
|
95
|
+
|
|
96
|
+
| File | Meaning |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `schedule.json` | Schedule ID, timezone, expected slot, containment status, and marker-derived evidence status or `unknown` |
|
|
99
|
+
| `attempt.json` | Run ID, PID, tool version, start time, and preflight values |
|
|
100
|
+
| `result.json` | Terminal result, failure code, estimates, and per-tier status |
|
|
101
|
+
| `success-local.json` | Last verified internal success |
|
|
102
|
+
| `success-vault.json` | Reserved for Slice 3 |
|
|
103
|
+
| `success-icloud.json` | Reserved for Slice 4 |
|
|
104
|
+
|
|
105
|
+
These records are diagnostic. They do not replace snapshot completion proof.
|
|
106
|
+
|
|
107
|
+
## Retention
|
|
108
|
+
|
|
109
|
+
Retention considers a directory eligible only when:
|
|
110
|
+
|
|
111
|
+
- It is not named `.in-progress-*`.
|
|
112
|
+
- `manifest.json` has `terminal_result: success`.
|
|
113
|
+
- `completion.json` has `status: complete`.
|
|
114
|
+
- The completion marker matches the manifest SHA-256.
|
|
115
|
+
- `.pinned` is absent.
|
|
116
|
+
|
|
117
|
+
The engine keeps the configured number of unpinned eligible snapshots plus every pinned snapshot. Unverified directories are preserved for later disposition.
|
|
118
|
+
|
|
119
|
+
## Fixture isolation
|
|
120
|
+
|
|
121
|
+
Tests set `LDM_BACKUP_REQUIRE_FIXTURE=1` and provide a unique `LDM_BACKUP_FIXTURE_ROOT` whose basename starts with `ldm-backup-fixture-`. Before planning or writing, the engine proves that all injected home, LDM, OpenClaw, Claude, Codex, workspace, destination, state, iCloud substitute, and lock paths resolve beneath that root.
|
|
122
|
+
|
|
123
|
+
The entire fixture suite runs with:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npm run test:backup-engine
|
|
127
|
+
```
|
|
106
128
|
|
|
107
|
-
|
|
108
|
-
- `node_modules/` ... reconstructable via npm install
|
|
109
|
-
- `.git/objects/` ... reconstructable via git fetch
|
|
110
|
-
- `backups/` ... avoids recursive backup
|
|
111
|
-
- `_trash/` ... already deleted content
|
|
112
|
-
- `*.tar.gz` ... avoids backing up old backup archives
|
|
129
|
+
It covers success, dry-run parity, exact policy IDs, required manifest evidence and generator failures, generator-less manifest short-circuiting over a tree containing a Unix socket, plan-to-capture size and same-size changes, capture-time reserve success and refusal, fatal during-capture mutation, concurrent and stale locks, missing and blocked required sources, SQLite failure, simulated ENOSPC, preflight failure, crash staging, atomic state, completion proof, configuration failures, UTC IDs, evidence status, retention, and path escape refusal.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wipcomputer/wip-ldm-os",
|
|
3
|
-
"version": "0.4.87-alpha.
|
|
3
|
+
"version": "0.4.87-alpha.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
|
|
6
6
|
"engines": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build:bridge": "cd src/bridge && npm install && npx tsup core.ts mcp-server.ts cli.ts openclaw.ts --format esm --dts --clean --outDir ../../dist/bridge",
|
|
20
20
|
"build": "npm run build:bridge",
|
|
21
|
-
"prepublishOnly": "npm run build:bridge && npm run validate:bin-manifest && npm run test:install-prompt-policy && npm run test:readme-install-prompt && npm run test:ldm-status-timeout && npm run test:ldm-status-concurrency && npm run test:legacy-npm-sources-migration",
|
|
21
|
+
"prepublishOnly": "npm run build:bridge && npm run validate:bin-manifest && npm run test:install-prompt-policy && npm run test:readme-install-prompt && npm run test:ldm-status-timeout && npm run test:ldm-status-concurrency && npm run test:legacy-npm-sources-migration && npm run test:ldm-install-bin-shim && npm run test:backup-engine",
|
|
22
22
|
"validate:bin-manifest": "node scripts/validate-bin-manifest.mjs",
|
|
23
23
|
"test:skill-frontmatter": "node scripts/test-skill-frontmatter.mjs",
|
|
24
24
|
"test:install-prompt-policy": "node scripts/test-install-prompt-policy.mjs",
|
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
"test:doctor-hook-dedupe": "node scripts/test-doctor-hook-dedupe.mjs",
|
|
52
52
|
"test:boot-dir-truth": "node scripts/test-boot-dir-truth.mjs",
|
|
53
53
|
"test:deploy-hook-ownership": "node scripts/test-deploy-hook-ownership.mjs",
|
|
54
|
-
"test:doctor-commit-deployed": "node scripts/test-doctor-commit-deployed.mjs"
|
|
54
|
+
"test:doctor-commit-deployed": "node scripts/test-doctor-commit-deployed.mjs",
|
|
55
|
+
"test:backup-engine": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-backup-engine.py"
|
|
55
56
|
},
|
|
56
57
|
"claudeCode": {
|
|
57
58
|
"hook": {
|
|
@@ -73,6 +74,17 @@
|
|
|
73
74
|
"source": "scripts/ldm-backup.sh",
|
|
74
75
|
"purpose": "daily backup; LaunchAgent at 03:00"
|
|
75
76
|
},
|
|
77
|
+
{
|
|
78
|
+
"name": "ldm-backup-engine.py",
|
|
79
|
+
"source": "scripts/ldm-backup-engine.py",
|
|
80
|
+
"purpose": "policy-driven backup engine"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"name": "ldm-backup-policy.json",
|
|
84
|
+
"source": "scripts/ldm-backup-policy.json",
|
|
85
|
+
"executable": false,
|
|
86
|
+
"purpose": "canonical backup source policy"
|
|
87
|
+
},
|
|
76
88
|
{
|
|
77
89
|
"name": "ldm-restore.sh",
|
|
78
90
|
"source": "scripts/ldm-restore.sh",
|