mirror-mind 0.39.5 → 0.40.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/hooks/log-assistant-reply.sh +3 -2
- package/.claude/hooks/log-session-end.sh +3 -2
- package/.claude/hooks/log-user-prompt.sh +3 -2
- package/.claude/hooks/mirror-inject.sh +3 -2
- package/.claude/hooks/session-start.sh +3 -2
- package/docs/releases/index.md +1 -0
- package/docs/releases/v0.40.0-next.1.md +103 -0
- package/lib/desktop.js +8 -0
- package/package.json +1 -1
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/plugins/mirror-mind/agy/pre-invocation.sh +3 -2
- package/plugins/mirror-mind/agy/session-start.sh +3 -2
- package/plugins/mirror-mind/agy/stop.sh +3 -2
- package/plugins/mirror-mind/hooks/log-assistant-reply.sh +3 -2
- package/plugins/mirror-mind/hooks/log-session-end.sh +3 -2
- package/plugins/mirror-mind/hooks/log-user-prompt.sh +3 -2
- package/plugins/mirror-mind/hooks/mirror-inject.sh +3 -2
- package/plugins/mirror-mind/hooks/session-start.sh +3 -2
- package/plugins/mirror-mind/plugin.json +1 -1
- package/ts/src/guards/claudePlugin.ts +3 -2
- package/ts/src/guards/retiredSurfaces.ts +9 -4
- package/ts/src/journey/journeyCalls.ts +9 -2
- package/ts/src/journey/journeyDeletion.ts +602 -0
|
@@ -74,8 +74,9 @@ ENTRY="$REPO_ROOT/bin/mirror-hook.js"
|
|
|
74
74
|
# runtime spawned this hook from. Anything else is noted with its reason, in
|
|
75
75
|
# the Pi extension's words, once a day, and the search goes on without it.
|
|
76
76
|
# "Absolute" is the MCP locator's pattern too, and it classifies exactly as
|
|
77
|
-
# path.win32.isAbsolute: under Git Bash the Windows installer writes
|
|
78
|
-
# C:\
|
|
77
|
+
# path.win32.isAbsolute: under Git Bash the Windows installer writes a
|
|
78
|
+
# drive-letter path, C:\Users\...\AppData\Local\Programs\MirrorMind\node\node.exe
|
|
79
|
+
# (PR #47).
|
|
79
80
|
NODE=""
|
|
80
81
|
if [ -n "${MIRROR_NODE:-}" ]; then
|
|
81
82
|
refused=""
|
|
@@ -74,8 +74,9 @@ ENTRY="$REPO_ROOT/bin/mirror-hook.js"
|
|
|
74
74
|
# runtime spawned this hook from. Anything else is noted with its reason, in
|
|
75
75
|
# the Pi extension's words, once a day, and the search goes on without it.
|
|
76
76
|
# "Absolute" is the MCP locator's pattern too, and it classifies exactly as
|
|
77
|
-
# path.win32.isAbsolute: under Git Bash the Windows installer writes
|
|
78
|
-
# C:\
|
|
77
|
+
# path.win32.isAbsolute: under Git Bash the Windows installer writes a
|
|
78
|
+
# drive-letter path, C:\Users\...\AppData\Local\Programs\MirrorMind\node\node.exe
|
|
79
|
+
# (PR #47).
|
|
79
80
|
NODE=""
|
|
80
81
|
if [ -n "${MIRROR_NODE:-}" ]; then
|
|
81
82
|
refused=""
|
|
@@ -74,8 +74,9 @@ ENTRY="$REPO_ROOT/bin/mirror-hook.js"
|
|
|
74
74
|
# runtime spawned this hook from. Anything else is noted with its reason, in
|
|
75
75
|
# the Pi extension's words, once a day, and the search goes on without it.
|
|
76
76
|
# "Absolute" is the MCP locator's pattern too, and it classifies exactly as
|
|
77
|
-
# path.win32.isAbsolute: under Git Bash the Windows installer writes
|
|
78
|
-
# C:\
|
|
77
|
+
# path.win32.isAbsolute: under Git Bash the Windows installer writes a
|
|
78
|
+
# drive-letter path, C:\Users\...\AppData\Local\Programs\MirrorMind\node\node.exe
|
|
79
|
+
# (PR #47).
|
|
79
80
|
NODE=""
|
|
80
81
|
if [ -n "${MIRROR_NODE:-}" ]; then
|
|
81
82
|
refused=""
|
|
@@ -74,8 +74,9 @@ ENTRY="$REPO_ROOT/bin/mirror-hook.js"
|
|
|
74
74
|
# runtime spawned this hook from. Anything else is noted with its reason, in
|
|
75
75
|
# the Pi extension's words, once a day, and the search goes on without it.
|
|
76
76
|
# "Absolute" is the MCP locator's pattern too, and it classifies exactly as
|
|
77
|
-
# path.win32.isAbsolute: under Git Bash the Windows installer writes
|
|
78
|
-
# C:\
|
|
77
|
+
# path.win32.isAbsolute: under Git Bash the Windows installer writes a
|
|
78
|
+
# drive-letter path, C:\Users\...\AppData\Local\Programs\MirrorMind\node\node.exe
|
|
79
|
+
# (PR #47).
|
|
79
80
|
NODE=""
|
|
80
81
|
if [ -n "${MIRROR_NODE:-}" ]; then
|
|
81
82
|
refused=""
|
|
@@ -74,8 +74,9 @@ ENTRY="$REPO_ROOT/bin/mirror-hook.js"
|
|
|
74
74
|
# runtime spawned this hook from. Anything else is noted with its reason, in
|
|
75
75
|
# the Pi extension's words, once a day, and the search goes on without it.
|
|
76
76
|
# "Absolute" is the MCP locator's pattern too, and it classifies exactly as
|
|
77
|
-
# path.win32.isAbsolute: under Git Bash the Windows installer writes
|
|
78
|
-
# C:\
|
|
77
|
+
# path.win32.isAbsolute: under Git Bash the Windows installer writes a
|
|
78
|
+
# drive-letter path, C:\Users\...\AppData\Local\Programs\MirrorMind\node\node.exe
|
|
79
|
+
# (PR #47).
|
|
79
80
|
NODE=""
|
|
80
81
|
if [ -n "${MIRROR_NODE:-}" ]; then
|
|
81
82
|
refused=""
|
package/docs/releases/index.md
CHANGED
|
@@ -12,6 +12,7 @@ Future release notes should use the structure defined in [Release Notes](../proc
|
|
|
12
12
|
|
|
13
13
|
## Releases
|
|
14
14
|
|
|
15
|
+
- [v0.40.0-next.1 — A Journey Can Go, Showing What Goes With It](v0.40.0-next.1.md) — preview under npm's `next`, read by no updater, for Nautilus for Mirror (the release train): CR200, `mirror-mind/desktop` gains `previewJourneyDeletion` and `deleteJourneyWithRecords`, deleting a Journey with every record that belongs to it after a preview that counts it, by digest, behind a verified backup; child Journeys refuse. Terminal users receive it with v0.40.0. No migration.
|
|
15
16
|
- [v0.39.5 — The Node You Named, or Why Not](v0.39.5.md) — patch release of CR192, from PR #47's thread (@rodrigoimmaginario): the hook wrappers, the plugin's MCP entry, and `runtime diagnose` use `MIRROR_NODE` only as an absolute path to an executable file, as the Pi extension does; a relative value is never run, and a refused one is named with its reason, once a day in `hooks.log` and on the MCP entry's stderr; the Windows installer's drive-letter path counts as absolute, in CI under Git Bash and on the installer. No migration.
|
|
16
17
|
- [v0.39.4 — The Reply Finds Its Prompt](v0.39.4.md) — patch release of PR #51 (@alannascimento1), CR166 and CR196: on Claude Code, a prompt typed while a turn runs and a journey entered in plain words no longer lose the reply; `nothing_waiting` in `hooks.log` means a lost reply again. No schema change, no migration.
|
|
17
18
|
- [v0.39.3 — Windows Gets Its Hooks](v0.39.3.md) — patch release of PR #53, PR #50, and PR #54 (@rodrigoimmaginario): the plugin's hook wrappers and MCP locator run the package's own bin when npm's global entry is a shell shim, so Claude Code hooks and the MCP server work on a Windows package install; `runtime diagnose` no longer counts its own verdict as a front-door error (CR139); the docs point a Windows reader to the installer in its own repository. No migration.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
digest: >
|
|
3
|
+
v0.40.0-next.1 is the first preview of the release train: published under npm's `next`
|
|
4
|
+
dist-tag, read by no updater, for Nautilus for Mirror, which embeds this Core. It carries
|
|
5
|
+
CR200: a host can delete a Journey together with every record that belongs to it, after
|
|
6
|
+
a preview that counts what will go, what other Journeys keep with a link cleared, and
|
|
7
|
+
which extension tables lose rows. The deletion acts only on the preview the person saw,
|
|
8
|
+
in one transaction, behind a named backup verified before any write. Terminal users
|
|
9
|
+
receive it with v0.40.0, when the train leaves. No schema change, no migration, and no
|
|
10
|
+
model or prompt change.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# v0.40.0-next.1 — A Journey Can Go, Showing What Goes With It
|
|
14
|
+
|
|
15
|
+
**Date:** 2026-11-06
|
|
16
|
+
|
|
17
|
+
**A preview, not a release users receive.** It is staged under `next`, its GitHub Release is
|
|
18
|
+
a prerelease, and `stable`, `latest`, `mirror update` and the release-notes reader all stay
|
|
19
|
+
on v0.39.5. It exists so Nautilus for Mirror can ship the change below before the train
|
|
20
|
+
leaves ([the release train](../process/versioning.md#the-release-train)).
|
|
21
|
+
|
|
22
|
+
## Highlights
|
|
23
|
+
|
|
24
|
+
- **A Journey and its records, deleted in one act**
|
|
25
|
+
([CR200](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr200-delete-a-journey-with-its-records-after-showing-what-will-go.md)).
|
|
26
|
+
`mirror-mind/desktop` gains `previewJourneyDeletion` and `deleteJourneyWithRecords`.
|
|
27
|
+
`deleteJourney` still deletes only an empty leaf.
|
|
28
|
+
- **The preview is the contract.** It counts the Journey's own records, its Conversations'
|
|
29
|
+
contents, other Journeys' records that only lose a link, and the `ext_*` rows that name
|
|
30
|
+
the Journey, by owning extension, and hashes all of it. The deletion takes that digest
|
|
31
|
+
and refuses any other.
|
|
32
|
+
- **A backup first, verified.** `backups/pre-journey-deletion-<id>-<stamp>-<random>.db`,
|
|
33
|
+
owner-only, taken under the write lock and read back as the same digest before anything
|
|
34
|
+
is deleted. A refused deletion removes it; a committed one keeps it.
|
|
35
|
+
- **One friction kept:** a Journey with child Journeys is refused, naming them.
|
|
36
|
+
- No schema change, no migration, and no model or prompt change.
|
|
37
|
+
|
|
38
|
+
## Where We Started
|
|
39
|
+
|
|
40
|
+
CV24.DS8 gave a host five Journey calls, and its `deleteJourney` refuses a Journey that
|
|
41
|
+
holds anything: conversations, memories, tasks, attachments, stories, change requests,
|
|
42
|
+
cursors. Nautilus for Mirror's Journey management (its RS028) asked for the act a person
|
|
43
|
+
means by "delete this Journey". Its Navigator decided on 2026-11-05 and 2026-11-06 that
|
|
44
|
+
everything that belongs to the Journey goes, that the confirmation shows it first, and,
|
|
45
|
+
where the Core cannot judge data it does not own, to trade refusal for transparency.
|
|
46
|
+
|
|
47
|
+
A long-lived home showed why "belongs" needed rules: 956 memories sit under a Journey other
|
|
48
|
+
than their conversation's, and 101 runtime sessions likewise. Deleting by reach would have
|
|
49
|
+
erased other Journeys' history.
|
|
50
|
+
|
|
51
|
+
## What Changed
|
|
52
|
+
|
|
53
|
+
**Four rules.** A record that belongs to the Journey goes. What belongs to a deleted
|
|
54
|
+
conversation or memory goes with it: messages, embeddings, provider call records, memory
|
|
55
|
+
access records. A record of another Journey that only points at something deleted stays,
|
|
56
|
+
with that link cleared. And an `ext_*` row goes when the table's `journey_id` or `journey`
|
|
57
|
+
column names the Journey exactly; the preview names the extension from `_ext_migrations`,
|
|
58
|
+
and a row an extension's own foreign key holds refuses the deletion as
|
|
59
|
+
`extension_rows_held`, naming the table.
|
|
60
|
+
|
|
61
|
+
**A closed inventory.** `JOURNEY_DELETION_INVENTORY` names every core table the deletion
|
|
62
|
+
touches, keeps or detaches, and a test reads the schema against it: a table that names a
|
|
63
|
+
Journey, or references a row the deletion removes, without a decision fails. Its first
|
|
64
|
+
run found `journey_mutation_receipts`, kept as the audit trail.
|
|
65
|
+
|
|
66
|
+
**Refusals.** `journey_changed` for a digest that no longer matches, `journey_has_children`,
|
|
67
|
+
`extension_rows_held`, `backup_failed`, and as before an unknown Journey, a malformed
|
|
68
|
+
request and a database that is not current. Every refusal writes nothing.
|
|
69
|
+
|
|
70
|
+
**Validated** in `journeyDeletion.test.ts` (11 tests, ten mutations each caught), in CI on
|
|
71
|
+
Ubuntu and macOS (PR #55), and end to end by Nautilus: its host drives both calls
|
|
72
|
+
against a scratch home, and its Navigator deleted Journeys in the app on 2026-11-06.
|
|
73
|
+
|
|
74
|
+
**Also in this preview.** [CR199](../project/refinement/rs010-cv22-oracle-and-port-hygiene/cr199-deliver-mirror-context-takes-three-to-seven-seconds-before-every-desktop-turn.md)
|
|
75
|
+
is captured: `deliverMirrorContext` takes three to seven seconds before every Desktop
|
|
76
|
+
turn, measured. And the release train itself (PR #56) is in
|
|
77
|
+
[versioning](../process/versioning.md#the-release-train).
|
|
78
|
+
|
|
79
|
+
## Conscious Exclusions
|
|
80
|
+
|
|
81
|
+
- No CLI command deletes a Journey with its records; a host is the only caller.
|
|
82
|
+
- No cascade through child Journeys: they are refused, by the Navigator's decision.
|
|
83
|
+
- Files on disk are the host's to decide; the Core touches only the database.
|
|
84
|
+
- An extension cannot yet take over its own cleanup; recorded for when the extension
|
|
85
|
+
mechanism exists (CV24.DS8).
|
|
86
|
+
- The test suite, run with `MIRROR_HOME` or `DB_PATH` inherited from a real home, writes
|
|
87
|
+
into that home. Found while validating this change; worth its own Change Request.
|
|
88
|
+
|
|
89
|
+
## What We Learned
|
|
90
|
+
|
|
91
|
+
A preview that is also the contract keeps both honest: the person confirms a list, the
|
|
92
|
+
digest makes the deletion that list and nothing more, and a home that changed in between
|
|
93
|
+
is told so instead of deleted differently.
|
|
94
|
+
|
|
95
|
+
## Upgrading
|
|
96
|
+
|
|
97
|
+
Nothing to do in the terminal: this preview is not offered by `mirror update`. A host that
|
|
98
|
+
embeds the Core pins `mirror-mind@0.40.0-next.1` from the registry.
|
|
99
|
+
|
|
100
|
+
## Next Horizon
|
|
101
|
+
|
|
102
|
+
v0.40.0 leaves with the next train, carrying this change to every install, with whatever
|
|
103
|
+
else merges before it.
|
package/lib/desktop.js
CHANGED
|
@@ -22,6 +22,7 @@ const migration = await import("../ts/src/db/migrateHome.ts");
|
|
|
22
22
|
const context = await import("../ts/src/mirror/deliverContext.ts");
|
|
23
23
|
const learning = await import("../ts/src/conversation/learnFromConversations.ts");
|
|
24
24
|
const journeys = await import("../ts/src/journey/journeyCalls.ts");
|
|
25
|
+
const journeyDeletion = await import("../ts/src/journey/journeyDeletion.ts");
|
|
25
26
|
|
|
26
27
|
export const DESKTOP_API_VERSION = 1;
|
|
27
28
|
export const inspectMigrationState = migrationReport.inspectMigrationState;
|
|
@@ -70,3 +71,10 @@ export const setJourneyProjectPath = journeys.setJourneyProjectPath;
|
|
|
70
71
|
export const moveJourney = journeys.moveJourney;
|
|
71
72
|
export const deleteJourney = journeys.deleteJourney;
|
|
72
73
|
export const JourneyCallRejectedError = journeys.JourneyCallRejectedError;
|
|
74
|
+
|
|
75
|
+
// Delete a Journey with its records, after showing what will go: the preview
|
|
76
|
+
// counts every record by rule and answers a digest; the deletion takes that
|
|
77
|
+
// digest back, refuses a home that no longer matches it, and runs behind a
|
|
78
|
+
// retained, verified backup it names. Rejections are `JourneyCallRejectedError`s.
|
|
79
|
+
export const previewJourneyDeletion = journeyDeletion.previewJourneyDeletion;
|
|
80
|
+
export const deleteJourneyWithRecords = journeyDeletion.deleteJourneyWithRecords;
|
package/package.json
CHANGED
|
@@ -100,8 +100,9 @@ fi
|
|
|
100
100
|
# runtime spawned this hook from. Anything else is noted with its reason, in
|
|
101
101
|
# the Pi extension's words, once a day, and the search goes on without it.
|
|
102
102
|
# "Absolute" is the MCP locator's pattern too, and it classifies exactly as
|
|
103
|
-
# path.win32.isAbsolute: under Git Bash the Windows installer writes
|
|
104
|
-
# C:\
|
|
103
|
+
# path.win32.isAbsolute: under Git Bash the Windows installer writes a
|
|
104
|
+
# drive-letter path, C:\Users\...\AppData\Local\Programs\MirrorMind\node\node.exe
|
|
105
|
+
# (PR #47).
|
|
105
106
|
NODE=""
|
|
106
107
|
if [ -n "${MIRROR_NODE:-}" ]; then
|
|
107
108
|
refused=""
|
|
@@ -100,8 +100,9 @@ fi
|
|
|
100
100
|
# runtime spawned this hook from. Anything else is noted with its reason, in
|
|
101
101
|
# the Pi extension's words, once a day, and the search goes on without it.
|
|
102
102
|
# "Absolute" is the MCP locator's pattern too, and it classifies exactly as
|
|
103
|
-
# path.win32.isAbsolute: under Git Bash the Windows installer writes
|
|
104
|
-
# C:\
|
|
103
|
+
# path.win32.isAbsolute: under Git Bash the Windows installer writes a
|
|
104
|
+
# drive-letter path, C:\Users\...\AppData\Local\Programs\MirrorMind\node\node.exe
|
|
105
|
+
# (PR #47).
|
|
105
106
|
NODE=""
|
|
106
107
|
if [ -n "${MIRROR_NODE:-}" ]; then
|
|
107
108
|
refused=""
|
|
@@ -100,8 +100,9 @@ fi
|
|
|
100
100
|
# runtime spawned this hook from. Anything else is noted with its reason, in
|
|
101
101
|
# the Pi extension's words, once a day, and the search goes on without it.
|
|
102
102
|
# "Absolute" is the MCP locator's pattern too, and it classifies exactly as
|
|
103
|
-
# path.win32.isAbsolute: under Git Bash the Windows installer writes
|
|
104
|
-
# C:\
|
|
103
|
+
# path.win32.isAbsolute: under Git Bash the Windows installer writes a
|
|
104
|
+
# drive-letter path, C:\Users\...\AppData\Local\Programs\MirrorMind\node\node.exe
|
|
105
|
+
# (PR #47).
|
|
105
106
|
NODE=""
|
|
106
107
|
if [ -n "${MIRROR_NODE:-}" ]; then
|
|
107
108
|
refused=""
|
|
@@ -95,8 +95,9 @@ fi
|
|
|
95
95
|
# runtime spawned this hook from. Anything else is noted with its reason, in
|
|
96
96
|
# the Pi extension's words, once a day, and the search goes on without it.
|
|
97
97
|
# "Absolute" is the MCP locator's pattern too, and it classifies exactly as
|
|
98
|
-
# path.win32.isAbsolute: under Git Bash the Windows installer writes
|
|
99
|
-
# C:\
|
|
98
|
+
# path.win32.isAbsolute: under Git Bash the Windows installer writes a
|
|
99
|
+
# drive-letter path, C:\Users\...\AppData\Local\Programs\MirrorMind\node\node.exe
|
|
100
|
+
# (PR #47).
|
|
100
101
|
NODE=""
|
|
101
102
|
if [ -n "${MIRROR_NODE:-}" ]; then
|
|
102
103
|
refused=""
|
|
@@ -95,8 +95,9 @@ fi
|
|
|
95
95
|
# runtime spawned this hook from. Anything else is noted with its reason, in
|
|
96
96
|
# the Pi extension's words, once a day, and the search goes on without it.
|
|
97
97
|
# "Absolute" is the MCP locator's pattern too, and it classifies exactly as
|
|
98
|
-
# path.win32.isAbsolute: under Git Bash the Windows installer writes
|
|
99
|
-
# C:\
|
|
98
|
+
# path.win32.isAbsolute: under Git Bash the Windows installer writes a
|
|
99
|
+
# drive-letter path, C:\Users\...\AppData\Local\Programs\MirrorMind\node\node.exe
|
|
100
|
+
# (PR #47).
|
|
100
101
|
NODE=""
|
|
101
102
|
if [ -n "${MIRROR_NODE:-}" ]; then
|
|
102
103
|
refused=""
|
|
@@ -95,8 +95,9 @@ fi
|
|
|
95
95
|
# runtime spawned this hook from. Anything else is noted with its reason, in
|
|
96
96
|
# the Pi extension's words, once a day, and the search goes on without it.
|
|
97
97
|
# "Absolute" is the MCP locator's pattern too, and it classifies exactly as
|
|
98
|
-
# path.win32.isAbsolute: under Git Bash the Windows installer writes
|
|
99
|
-
# C:\
|
|
98
|
+
# path.win32.isAbsolute: under Git Bash the Windows installer writes a
|
|
99
|
+
# drive-letter path, C:\Users\...\AppData\Local\Programs\MirrorMind\node\node.exe
|
|
100
|
+
# (PR #47).
|
|
100
101
|
NODE=""
|
|
101
102
|
if [ -n "${MIRROR_NODE:-}" ]; then
|
|
102
103
|
refused=""
|
|
@@ -95,8 +95,9 @@ fi
|
|
|
95
95
|
# runtime spawned this hook from. Anything else is noted with its reason, in
|
|
96
96
|
# the Pi extension's words, once a day, and the search goes on without it.
|
|
97
97
|
# "Absolute" is the MCP locator's pattern too, and it classifies exactly as
|
|
98
|
-
# path.win32.isAbsolute: under Git Bash the Windows installer writes
|
|
99
|
-
# C:\
|
|
98
|
+
# path.win32.isAbsolute: under Git Bash the Windows installer writes a
|
|
99
|
+
# drive-letter path, C:\Users\...\AppData\Local\Programs\MirrorMind\node\node.exe
|
|
100
|
+
# (PR #47).
|
|
100
101
|
NODE=""
|
|
101
102
|
if [ -n "${MIRROR_NODE:-}" ]; then
|
|
102
103
|
refused=""
|
|
@@ -95,8 +95,9 @@ fi
|
|
|
95
95
|
# runtime spawned this hook from. Anything else is noted with its reason, in
|
|
96
96
|
# the Pi extension's words, once a day, and the search goes on without it.
|
|
97
97
|
# "Absolute" is the MCP locator's pattern too, and it classifies exactly as
|
|
98
|
-
# path.win32.isAbsolute: under Git Bash the Windows installer writes
|
|
99
|
-
# C:\
|
|
98
|
+
# path.win32.isAbsolute: under Git Bash the Windows installer writes a
|
|
99
|
+
# drive-letter path, C:\Users\...\AppData\Local\Programs\MirrorMind\node\node.exe
|
|
100
|
+
# (PR #47).
|
|
100
101
|
NODE=""
|
|
101
102
|
if [ -n "${MIRROR_NODE:-}" ]; then
|
|
102
103
|
refused=""
|
|
@@ -87,8 +87,9 @@ export const GLOBAL_BIN_DIRS = ['"$HOME/.nvm/current/bin"', "/opt/homebrew/bin",
|
|
|
87
87
|
* `scripts/generate_hook_wrappers.sh` and into the locator below, and held
|
|
88
88
|
* equal by `mirrorNodeRule.test.ts`. It classifies exactly as
|
|
89
89
|
* `path.win32.isAbsolute`, on every platform: under Git Bash the Windows
|
|
90
|
-
* installer writes
|
|
91
|
-
*
|
|
90
|
+
* installer writes a drive-letter path,
|
|
91
|
+
* `C:\Users\…\AppData\Local\Programs\MirrorMind\node\node.exe`, which a
|
|
92
|
+
* POSIX-only pattern would refuse on every hook (PR #47). On POSIX a drive or backslash
|
|
92
93
|
* value names no file, and is refused as missing.
|
|
93
94
|
*/
|
|
94
95
|
export const ABSOLUTE_PATH_CASE = "/*|\\\\*|[A-Za-z]:[\\\\/]*";
|
|
@@ -500,8 +500,10 @@ export const RETIRED: readonly RetiredSurface[] = [
|
|
|
500
500
|
// Windows spells `C:\...` as `/C:/...` -- and read as one it made every
|
|
501
501
|
// install under such a directory `unknown` (PR #42, CR140). The fix is
|
|
502
502
|
// `import.meta.dirname` or `fileURLToPath`; this row keeps the idiom from
|
|
503
|
-
// coming back in code.
|
|
504
|
-
//
|
|
503
|
+
// coming back in code. The tests and smokes are in scope too (issue #44):
|
|
504
|
+
// twenty fixture and CLI paths there read a URL's pathname, so the suite
|
|
505
|
+
// failed when run from a checkout under such a directory. Only the files
|
|
506
|
+
// that spell the bug to pin it are exempt.
|
|
505
507
|
surfaceId: "url-pathname-as-path",
|
|
506
508
|
story: "PR #42 (CR140)",
|
|
507
509
|
absentPaths: [],
|
|
@@ -509,8 +511,11 @@ export const RETIRED: readonly RetiredSurface[] = [
|
|
|
509
511
|
// off a variable (`url.pathname`). Prose writes `URL.pathname`, capital U,
|
|
510
512
|
// which neither pattern matches, so a comment may still name the cause.
|
|
511
513
|
forbiddenPatterns: ["\\)\\.pathname\\b", "\\b[a-z_$][\\w$]*\\.pathname\\b"],
|
|
512
|
-
residueScope: ["ts/src/", "ts/scripts/"],
|
|
513
|
-
exemptions: {
|
|
514
|
+
residueScope: ["ts/src/", "ts/scripts/", "ts/test/", "ts/smoke/"],
|
|
515
|
+
exemptions: {
|
|
516
|
+
"ts/test/runtime/installKind.test.ts":
|
|
517
|
+
"asserts what a URL's pathname spells for a non-ASCII checkout, the bug it pins",
|
|
518
|
+
},
|
|
514
519
|
},
|
|
515
520
|
{
|
|
516
521
|
surfaceId: "gemini-cli",
|
|
@@ -49,12 +49,19 @@ export type JourneyCallRejection =
|
|
|
49
49
|
| "unknown_parent"
|
|
50
50
|
| "unknown_journey"
|
|
51
51
|
| "journey_changed"
|
|
52
|
-
| "journey_not_empty"
|
|
52
|
+
| "journey_not_empty"
|
|
53
|
+
// `deleteJourneyWithRecords` (journeyDeletion.ts):
|
|
54
|
+
| "journey_has_children"
|
|
55
|
+
| "extension_rows_held"
|
|
56
|
+
| "backup_failed";
|
|
53
57
|
|
|
54
58
|
/** A call's refusal, named with the administration's codes where it had one. Nothing was written. */
|
|
55
59
|
export class JourneyCallRejectedError extends Error {
|
|
56
60
|
readonly reason: JourneyCallRejection;
|
|
57
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* For `journey_not_empty`: the association classes that hold records. For
|
|
63
|
+
* `journey_has_children`: the child Journeys. For `extension_rows_held`: the table.
|
|
64
|
+
*/
|
|
58
65
|
readonly populated: string[];
|
|
59
66
|
|
|
60
67
|
constructor(reason: JourneyCallRejection, populated: string[] = []) {
|
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
// Delete a Journey with its records — for a host that shows what will go first.
|
|
2
|
+
//
|
|
3
|
+
// `deleteJourney` (CV24.DS8) takes only an empty leaf: `removeJourney` refuses a
|
|
4
|
+
// Journey with any associated record, so a Journey no longer needed has to be
|
|
5
|
+
// emptied by hand. Mirror Desktop's RS028 asked for one act instead, and its
|
|
6
|
+
// Navigator decided what each record does (2026-11-05/06). Those decisions are
|
|
7
|
+
// the four rules below, and the principle under them is transparency rather than
|
|
8
|
+
// refusal: the host shows everything that will change, and the act refuses only
|
|
9
|
+
// what it cannot do safely.
|
|
10
|
+
//
|
|
11
|
+
// 1. A record that is the Journey's own is deleted: its row, its journey path,
|
|
12
|
+
// a `journey:<id>` scene orientation, its identity integrations,
|
|
13
|
+
// conversations, memories, tasks, attachments, runtime sessions, explorer
|
|
14
|
+
// and refinement stories, change requests and refinement cursor.
|
|
15
|
+
// 2. What belongs to a deleted conversation or memory goes with it: messages,
|
|
16
|
+
// summary embeddings, provider calls and memory access log entries.
|
|
17
|
+
// Discarding ONE conversation keeps its provider calls
|
|
18
|
+
// (`conversationDeletion.ts`: "a provider call still happened"); deleting a
|
|
19
|
+
// Journey takes them, because their prompts are the Journey's own words and
|
|
20
|
+
// the person asked for the Journey to be gone.
|
|
21
|
+
// 3. Another Journey's record that only points at something deleted is kept,
|
|
22
|
+
// its link cleared. Memories are filed under the Journey they are about, not
|
|
23
|
+
// the conversation they came from: in one long-lived home, 956 memories
|
|
24
|
+
// came from a conversation of a different Journey. Deleting them with the
|
|
25
|
+
// conversation would delete other Journeys' history.
|
|
26
|
+
// 4. An extension row that names the Journey -- in a column called exactly
|
|
27
|
+
// `journey_id` or `journey` of an `ext_*` table -- is deleted, and the
|
|
28
|
+
// preview names the extension that owns the table. The core does not know
|
|
29
|
+
// what the row means; it does not need to, because the person sees it named
|
|
30
|
+
// before confirming. A table without such a column is never searched.
|
|
31
|
+
//
|
|
32
|
+
// One friction is kept: a Journey with child Journeys is refused, naming them,
|
|
33
|
+
// so the act never cascades silently through the tree.
|
|
34
|
+
//
|
|
35
|
+
// Two calls. `previewJourneyDeletion` reads, and answers every count with a
|
|
36
|
+
// digest. `deleteJourneyWithRecords` takes that digest back and refuses with
|
|
37
|
+
// `journey_changed` when the home no longer matches it, so the act deletes what
|
|
38
|
+
// the person was shown and nothing else. Under one immediate transaction it
|
|
39
|
+
// re-reads the preview, takes a backup with a name never reused
|
|
40
|
+
// (`backups/pre-journey-deletion-<id>-<UTC stamp>-<random>.db`, 0600) and
|
|
41
|
+
// verifies that the backup reads as the same preview, and only then deletes.
|
|
42
|
+
// The front door's fixed-name pre-write snapshot is replaced by the next write,
|
|
43
|
+
// a turn's append included, so it cannot be the undo for a deletion. The core
|
|
44
|
+
// never deletes the named backup.
|
|
45
|
+
|
|
46
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
47
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
48
|
+
import { dirname, join } from "node:path";
|
|
49
|
+
import { deleteConversation } from "#conversation/conversationDeletion.ts";
|
|
50
|
+
import { DatabaseNotCurrentError } from "#conversation/hostCalls.ts";
|
|
51
|
+
import {
|
|
52
|
+
type Database,
|
|
53
|
+
openDatabaseReadOnly,
|
|
54
|
+
prepareArchiveSnapshot,
|
|
55
|
+
snapshotDatabaseTo,
|
|
56
|
+
type WritableDatabase,
|
|
57
|
+
withTransaction,
|
|
58
|
+
} from "#db/database.ts";
|
|
59
|
+
import { inspectMigrationState } from "#db/migrationReport.ts";
|
|
60
|
+
import { openLiveWriteDatabase } from "#frontDoor/liveBackup.ts";
|
|
61
|
+
import { JourneyCallRejectedError, type JourneyCallResult } from "#journey/journeyCalls.ts";
|
|
62
|
+
import { buildJourneyTree, type JourneyTree, type JourneyTreeRow } from "#journey/journeyTree.ts";
|
|
63
|
+
|
|
64
|
+
/** Rule 1: the Journey's own records, deleted with it. */
|
|
65
|
+
export interface JourneyOwnRecords {
|
|
66
|
+
journey_paths: number;
|
|
67
|
+
scene_orientations: number;
|
|
68
|
+
identity_integrations: number;
|
|
69
|
+
conversations: number;
|
|
70
|
+
memories: number;
|
|
71
|
+
tasks: number;
|
|
72
|
+
attachments: number;
|
|
73
|
+
runtime_sessions: number;
|
|
74
|
+
explorer_stories: number;
|
|
75
|
+
refinement_stories: number;
|
|
76
|
+
change_requests: number;
|
|
77
|
+
refinement_cursors: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Rule 2: what belongs to the deleted conversations and memories, deleted with them. */
|
|
81
|
+
export interface JourneyOwnedContents {
|
|
82
|
+
messages: number;
|
|
83
|
+
conversation_embeddings: number;
|
|
84
|
+
llm_calls: number;
|
|
85
|
+
memory_access_log: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Rule 3: other Journeys' rows that point at a deleted row, kept with the link cleared. */
|
|
89
|
+
export interface JourneyDetachedRecords {
|
|
90
|
+
memories: number;
|
|
91
|
+
runtime_sessions: number;
|
|
92
|
+
identity_integrations: number;
|
|
93
|
+
change_requests: number;
|
|
94
|
+
refinement_cursors: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Rule 4: one extension table's rows that name the Journey. */
|
|
98
|
+
export interface JourneyExtensionRows {
|
|
99
|
+
/** The installed extension whose `ext_<id>_` prefix the table carries, or null when none does. */
|
|
100
|
+
extension: string | null;
|
|
101
|
+
table: string;
|
|
102
|
+
rows: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface JourneyDeletionPreview {
|
|
106
|
+
journeyId: string;
|
|
107
|
+
/** Child Journeys, by key. Any child refuses the deletion. */
|
|
108
|
+
children: string[];
|
|
109
|
+
records: JourneyOwnRecords;
|
|
110
|
+
contents: JourneyOwnedContents;
|
|
111
|
+
detached: JourneyDetachedRecords;
|
|
112
|
+
/** Only tables with at least one row naming the Journey, ordered by table. */
|
|
113
|
+
extensions: JourneyExtensionRows[];
|
|
114
|
+
/** SHA-256 over everything above and the Journey's row. The deletion refuses any other. */
|
|
115
|
+
digest: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface JourneyDeletionBackup {
|
|
119
|
+
path: string;
|
|
120
|
+
sha256: string;
|
|
121
|
+
sizeBytes: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface JourneyDeletionReceipt extends JourneyCallResult {
|
|
125
|
+
/** The preview the deletion acted on: the one the person confirmed. */
|
|
126
|
+
deleted: JourneyDeletionPreview;
|
|
127
|
+
backup: JourneyDeletionBackup;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The closed inventory, read by `journeyDeletion.test.ts` against the schema:
|
|
132
|
+
* a core table that names a Journey, or that references a row the deletion
|
|
133
|
+
* removes, without a decision here fails that test.
|
|
134
|
+
*/
|
|
135
|
+
export const JOURNEY_DELETION_INVENTORY = {
|
|
136
|
+
/** Core tables that name a Journey, and how. */
|
|
137
|
+
journeyColumns: {
|
|
138
|
+
identity: "key, in the journey and journey_path layers; journey:<id> in scene_orientation",
|
|
139
|
+
identity_integrations: "key, in the journey layer",
|
|
140
|
+
conversations: "journey",
|
|
141
|
+
memories: "journey",
|
|
142
|
+
tasks: "journey",
|
|
143
|
+
attachments: "journey_id",
|
|
144
|
+
runtime_sessions: "journey",
|
|
145
|
+
exploratory_stories: "journey",
|
|
146
|
+
builder_refinement_stories: "journey",
|
|
147
|
+
builder_change_requests: "journey",
|
|
148
|
+
builder_refinement_cursors: "journey",
|
|
149
|
+
},
|
|
150
|
+
/**
|
|
151
|
+
* Core tables that name a Journey and are deliberately left alone:
|
|
152
|
+
* `journey_mutation_receipts` is the administration's audit of what was done
|
|
153
|
+
* to the tree, a deletion included.
|
|
154
|
+
*/
|
|
155
|
+
kept: ["journey_mutation_receipts"],
|
|
156
|
+
/** Tables whose rows the deletion removes, so references into them need a decision. */
|
|
157
|
+
deletedTables: [
|
|
158
|
+
"conversations",
|
|
159
|
+
"memories",
|
|
160
|
+
"builder_refinement_stories",
|
|
161
|
+
"builder_change_requests",
|
|
162
|
+
"identity",
|
|
163
|
+
"identity_integrations",
|
|
164
|
+
],
|
|
165
|
+
references: {
|
|
166
|
+
/** Deleted with the row they reference (rule 2). */
|
|
167
|
+
deleted: [
|
|
168
|
+
"messages.conversation_id",
|
|
169
|
+
"conversation_embeddings.conversation_id",
|
|
170
|
+
"llm_calls.conversation_id",
|
|
171
|
+
"memory_access_log.memory_id",
|
|
172
|
+
],
|
|
173
|
+
/** Kept, the reference cleared, when the row is another Journey's (rule 3). */
|
|
174
|
+
unlinked: [
|
|
175
|
+
"memories.conversation_id",
|
|
176
|
+
"runtime_sessions.conversation_id",
|
|
177
|
+
"identity_integrations.conversation_id",
|
|
178
|
+
"identity_integrations.journal_id",
|
|
179
|
+
"builder_change_requests.refinement_story_id",
|
|
180
|
+
"builder_refinement_cursors.active_refinement_story_id",
|
|
181
|
+
"builder_refinement_cursors.active_change_request_id",
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
} as const;
|
|
185
|
+
|
|
186
|
+
const EXTENSION_JOURNEY_COLUMNS = ["journey_id", "journey"] as const;
|
|
187
|
+
|
|
188
|
+
function reject(
|
|
189
|
+
reason: ConstructorParameters<typeof JourneyCallRejectedError>[0],
|
|
190
|
+
populated?: string[],
|
|
191
|
+
): never {
|
|
192
|
+
throw new JourneyCallRejectedError(reason, populated);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function text(value: unknown): string {
|
|
196
|
+
if (typeof value !== "string" || value.length === 0) reject("invalid_request");
|
|
197
|
+
return value;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function number(db: Database, sql: string, ...params: string[]): number {
|
|
201
|
+
const value = db.prepare(sql).get(...params)?.n;
|
|
202
|
+
if (typeof value === "number") return value;
|
|
203
|
+
if (typeof value === "bigint") return Number(value);
|
|
204
|
+
throw new Error("journey deletion count did not return a number");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function ids(db: Database, sql: string, journeyId: string): string[] {
|
|
208
|
+
return db
|
|
209
|
+
.prepare(sql)
|
|
210
|
+
.all(journeyId)
|
|
211
|
+
.map((row) => String(row.id));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The keys of the rows rule 2 and rule 3 follow from, as JSON arrays for `json_each`. */
|
|
215
|
+
interface DeletedKeys {
|
|
216
|
+
conversations: string;
|
|
217
|
+
memories: string;
|
|
218
|
+
stories: string;
|
|
219
|
+
changeRequests: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function deletedKeys(db: Database, journeyId: string): DeletedKeys {
|
|
223
|
+
return {
|
|
224
|
+
conversations: JSON.stringify(
|
|
225
|
+
ids(db, "SELECT id FROM conversations WHERE journey = ?", journeyId),
|
|
226
|
+
),
|
|
227
|
+
memories: JSON.stringify(ids(db, "SELECT id FROM memories WHERE journey = ?", journeyId)),
|
|
228
|
+
stories: JSON.stringify(
|
|
229
|
+
ids(db, "SELECT id FROM builder_refinement_stories WHERE journey = ?", journeyId),
|
|
230
|
+
),
|
|
231
|
+
changeRequests: JSON.stringify(
|
|
232
|
+
ids(db, "SELECT id FROM builder_change_requests WHERE journey = ?", journeyId),
|
|
233
|
+
),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const IN = (param: string) => `IN (SELECT value FROM json_each(${param}))`;
|
|
238
|
+
|
|
239
|
+
/** `ext_*` tables with a Journey column, each with its owning extension when one is installed. */
|
|
240
|
+
function extensionTables(
|
|
241
|
+
db: Database,
|
|
242
|
+
): { table: string; columns: string[]; extension: string | null }[] {
|
|
243
|
+
const installed = db
|
|
244
|
+
.prepare("SELECT DISTINCT extension_id AS id FROM _ext_migrations")
|
|
245
|
+
.all()
|
|
246
|
+
.map((row) => String(row.id));
|
|
247
|
+
const owners = installed
|
|
248
|
+
.map((id) => ({ id, prefix: `ext_${id.replaceAll("-", "_")}_` }))
|
|
249
|
+
.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
250
|
+
return db
|
|
251
|
+
.prepare(
|
|
252
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'ext\\_%' ESCAPE '\\' ORDER BY name",
|
|
253
|
+
)
|
|
254
|
+
.all()
|
|
255
|
+
.map((row) => String(row.name))
|
|
256
|
+
.map((table) => {
|
|
257
|
+
const present = new Set(
|
|
258
|
+
db
|
|
259
|
+
.prepare(`PRAGMA table_info("${table.replaceAll('"', '""')}")`)
|
|
260
|
+
.all()
|
|
261
|
+
.map((row) => String(row.name)),
|
|
262
|
+
);
|
|
263
|
+
return {
|
|
264
|
+
table,
|
|
265
|
+
columns: EXTENSION_JOURNEY_COLUMNS.filter((column) => present.has(column)),
|
|
266
|
+
extension: owners.find((owner) => table.startsWith(owner.prefix))?.id ?? null,
|
|
267
|
+
};
|
|
268
|
+
})
|
|
269
|
+
.filter((entry) => entry.columns.length > 0);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function extensionWhere(columns: readonly string[]): string {
|
|
273
|
+
return columns.map((column) => `"${column}" = ?`).join(" OR ");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function journeyRow(db: Database, journeyId: string): { updatedAt: string } | undefined {
|
|
277
|
+
const row = db
|
|
278
|
+
.prepare("SELECT updated_at FROM identity WHERE layer = 'journey' AND key = ?")
|
|
279
|
+
.get(journeyId);
|
|
280
|
+
return row === undefined ? undefined : { updatedAt: String(row.updated_at) };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Read everything the deletion would change. Shared by the preview, the act and the backup check. */
|
|
284
|
+
function readPreview(db: Database, journeyId: string): JourneyDeletionPreview {
|
|
285
|
+
const row = journeyRow(db, journeyId);
|
|
286
|
+
if (!row) reject("unknown_journey");
|
|
287
|
+
const keys = deletedKeys(db, journeyId);
|
|
288
|
+
const j = journeyId;
|
|
289
|
+
const children = db
|
|
290
|
+
.prepare(
|
|
291
|
+
`SELECT key FROM identity
|
|
292
|
+
WHERE layer = 'journey'
|
|
293
|
+
AND CASE WHEN json_valid(metadata) THEN json_extract(metadata, '$.parent_journey') END = ?
|
|
294
|
+
ORDER BY key`,
|
|
295
|
+
)
|
|
296
|
+
.all(j)
|
|
297
|
+
.map((child) => String(child.key));
|
|
298
|
+
const records: JourneyOwnRecords = {
|
|
299
|
+
journey_paths: number(
|
|
300
|
+
db,
|
|
301
|
+
"SELECT COUNT(*) AS n FROM identity WHERE layer = 'journey_path' AND key = ?",
|
|
302
|
+
j,
|
|
303
|
+
),
|
|
304
|
+
scene_orientations: number(
|
|
305
|
+
db,
|
|
306
|
+
"SELECT COUNT(*) AS n FROM identity WHERE layer = 'scene_orientation' AND key = 'journey:' || ?",
|
|
307
|
+
j,
|
|
308
|
+
),
|
|
309
|
+
identity_integrations: number(
|
|
310
|
+
db,
|
|
311
|
+
"SELECT COUNT(*) AS n FROM identity_integrations WHERE layer = 'journey' AND key = ?",
|
|
312
|
+
j,
|
|
313
|
+
),
|
|
314
|
+
conversations: number(db, "SELECT COUNT(*) AS n FROM conversations WHERE journey = ?", j),
|
|
315
|
+
memories: number(db, "SELECT COUNT(*) AS n FROM memories WHERE journey = ?", j),
|
|
316
|
+
tasks: number(db, "SELECT COUNT(*) AS n FROM tasks WHERE journey = ?", j),
|
|
317
|
+
attachments: number(db, "SELECT COUNT(*) AS n FROM attachments WHERE journey_id = ?", j),
|
|
318
|
+
runtime_sessions: number(db, "SELECT COUNT(*) AS n FROM runtime_sessions WHERE journey = ?", j),
|
|
319
|
+
explorer_stories: number(
|
|
320
|
+
db,
|
|
321
|
+
"SELECT COUNT(*) AS n FROM exploratory_stories WHERE journey = ?",
|
|
322
|
+
j,
|
|
323
|
+
),
|
|
324
|
+
refinement_stories: number(
|
|
325
|
+
db,
|
|
326
|
+
"SELECT COUNT(*) AS n FROM builder_refinement_stories WHERE journey = ?",
|
|
327
|
+
j,
|
|
328
|
+
),
|
|
329
|
+
change_requests: number(
|
|
330
|
+
db,
|
|
331
|
+
"SELECT COUNT(*) AS n FROM builder_change_requests WHERE journey = ?",
|
|
332
|
+
j,
|
|
333
|
+
),
|
|
334
|
+
refinement_cursors: number(
|
|
335
|
+
db,
|
|
336
|
+
"SELECT COUNT(*) AS n FROM builder_refinement_cursors WHERE journey = ?",
|
|
337
|
+
j,
|
|
338
|
+
),
|
|
339
|
+
};
|
|
340
|
+
const contents: JourneyOwnedContents = {
|
|
341
|
+
messages: number(
|
|
342
|
+
db,
|
|
343
|
+
`SELECT COUNT(*) AS n FROM messages WHERE conversation_id ${IN("?")}`,
|
|
344
|
+
keys.conversations,
|
|
345
|
+
),
|
|
346
|
+
conversation_embeddings: number(
|
|
347
|
+
db,
|
|
348
|
+
`SELECT COUNT(*) AS n FROM conversation_embeddings WHERE conversation_id ${IN("?")}`,
|
|
349
|
+
keys.conversations,
|
|
350
|
+
),
|
|
351
|
+
llm_calls: number(
|
|
352
|
+
db,
|
|
353
|
+
`SELECT COUNT(*) AS n FROM llm_calls WHERE conversation_id ${IN("?")}`,
|
|
354
|
+
keys.conversations,
|
|
355
|
+
),
|
|
356
|
+
memory_access_log: number(
|
|
357
|
+
db,
|
|
358
|
+
`SELECT COUNT(*) AS n FROM memory_access_log WHERE memory_id ${IN("?")}`,
|
|
359
|
+
keys.memories,
|
|
360
|
+
),
|
|
361
|
+
};
|
|
362
|
+
const detached: JourneyDetachedRecords = {
|
|
363
|
+
memories: number(
|
|
364
|
+
db,
|
|
365
|
+
`SELECT COUNT(*) AS n FROM memories WHERE conversation_id ${IN("?")} AND journey IS NOT ?`,
|
|
366
|
+
keys.conversations,
|
|
367
|
+
j,
|
|
368
|
+
),
|
|
369
|
+
runtime_sessions: number(
|
|
370
|
+
db,
|
|
371
|
+
`SELECT COUNT(*) AS n FROM runtime_sessions WHERE conversation_id ${IN("?")} AND journey IS NOT ?`,
|
|
372
|
+
keys.conversations,
|
|
373
|
+
j,
|
|
374
|
+
),
|
|
375
|
+
identity_integrations: number(
|
|
376
|
+
db,
|
|
377
|
+
`SELECT COUNT(*) AS n FROM identity_integrations
|
|
378
|
+
WHERE (conversation_id ${IN("?")} OR journal_id ${IN("?")})
|
|
379
|
+
AND NOT (layer = 'journey' AND key = ?)`,
|
|
380
|
+
keys.conversations,
|
|
381
|
+
keys.memories,
|
|
382
|
+
j,
|
|
383
|
+
),
|
|
384
|
+
change_requests: number(
|
|
385
|
+
db,
|
|
386
|
+
`SELECT COUNT(*) AS n FROM builder_change_requests WHERE refinement_story_id ${IN("?")} AND journey IS NOT ?`,
|
|
387
|
+
keys.stories,
|
|
388
|
+
j,
|
|
389
|
+
),
|
|
390
|
+
refinement_cursors: number(
|
|
391
|
+
db,
|
|
392
|
+
`SELECT COUNT(*) AS n FROM builder_refinement_cursors
|
|
393
|
+
WHERE (active_refinement_story_id ${IN("?")} OR active_change_request_id ${IN("?")})
|
|
394
|
+
AND journey IS NOT ?`,
|
|
395
|
+
keys.stories,
|
|
396
|
+
keys.changeRequests,
|
|
397
|
+
j,
|
|
398
|
+
),
|
|
399
|
+
};
|
|
400
|
+
const extensions = extensionTables(db)
|
|
401
|
+
.map(({ table, columns, extension }) => ({
|
|
402
|
+
extension,
|
|
403
|
+
table,
|
|
404
|
+
rows: number(
|
|
405
|
+
db,
|
|
406
|
+
`SELECT COUNT(*) AS n FROM "${table}" WHERE ${extensionWhere(columns)}`,
|
|
407
|
+
...columns.map(() => j),
|
|
408
|
+
),
|
|
409
|
+
}))
|
|
410
|
+
.filter((entry) => entry.rows > 0);
|
|
411
|
+
const body = {
|
|
412
|
+
journeyId,
|
|
413
|
+
updatedAt: row.updatedAt,
|
|
414
|
+
children,
|
|
415
|
+
records,
|
|
416
|
+
contents,
|
|
417
|
+
detached,
|
|
418
|
+
extensions,
|
|
419
|
+
};
|
|
420
|
+
const digest = createHash("sha256").update(JSON.stringify(body)).digest("hex");
|
|
421
|
+
return { journeyId, children, records, contents, detached, extensions, digest };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function readTree(db: Database): JourneyTree {
|
|
425
|
+
return buildJourneyTree(
|
|
426
|
+
db
|
|
427
|
+
.prepare(
|
|
428
|
+
"SELECT key, content, updated_at, metadata FROM identity WHERE layer = 'journey' ORDER BY key",
|
|
429
|
+
)
|
|
430
|
+
.all() as unknown as JourneyTreeRow[],
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function requireCurrent(dbPath: string): void {
|
|
435
|
+
const report = inspectMigrationState(dbPath);
|
|
436
|
+
if (report.state !== "current") throw new DatabaseNotCurrentError(report);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** What deleting `journeyId` would change, without changing anything. */
|
|
440
|
+
export function previewJourneyDeletion(
|
|
441
|
+
dbPath: string,
|
|
442
|
+
request: { journeyId: string },
|
|
443
|
+
): JourneyDeletionPreview {
|
|
444
|
+
const journeyId = text(request?.journeyId);
|
|
445
|
+
requireCurrent(dbPath);
|
|
446
|
+
const db = openDatabaseReadOnly(dbPath);
|
|
447
|
+
try {
|
|
448
|
+
return readPreview(db, journeyId);
|
|
449
|
+
} finally {
|
|
450
|
+
db.close();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function stamp(date: Date): string {
|
|
455
|
+
return date
|
|
456
|
+
.toISOString()
|
|
457
|
+
.replace(/[-:]/g, "")
|
|
458
|
+
.replace(/\.\d{3}Z$/, "Z");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function removeBackup(path: string): void {
|
|
462
|
+
for (const file of [path, `${path}-wal`, `${path}-shm`]) rmSync(file, { force: true });
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Take the named backup and verify that it reads as `expected`: the home the
|
|
467
|
+
* person was shown. Runs while the caller holds the write lock and has written
|
|
468
|
+
* nothing, so the snapshot is the state the deletion is about to change; only a
|
|
469
|
+
* defective snapshot can read differently. Exported so that check has a test.
|
|
470
|
+
*/
|
|
471
|
+
export function verifiedJourneyDeletionBackup(
|
|
472
|
+
dbPath: string,
|
|
473
|
+
expected: JourneyDeletionPreview,
|
|
474
|
+
): JourneyDeletionBackup {
|
|
475
|
+
const dir = join(dirname(dbPath), "backups");
|
|
476
|
+
const path = join(
|
|
477
|
+
dir,
|
|
478
|
+
`pre-journey-deletion-${expected.journeyId}-${stamp(new Date())}-${randomBytes(6).toString("hex")}.db`,
|
|
479
|
+
);
|
|
480
|
+
const fail = (): never => {
|
|
481
|
+
removeBackup(path);
|
|
482
|
+
return reject("backup_failed");
|
|
483
|
+
};
|
|
484
|
+
try {
|
|
485
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
486
|
+
snapshotDatabaseTo(dbPath, path);
|
|
487
|
+
chmodSync(path, 0o600);
|
|
488
|
+
} catch {
|
|
489
|
+
return fail();
|
|
490
|
+
}
|
|
491
|
+
if (!prepareArchiveSnapshot(path).ok) return fail();
|
|
492
|
+
let image: JourneyDeletionPreview | undefined;
|
|
493
|
+
const db = openDatabaseReadOnly(path);
|
|
494
|
+
try {
|
|
495
|
+
image = readPreview(db, expected.journeyId);
|
|
496
|
+
} catch {
|
|
497
|
+
image = undefined;
|
|
498
|
+
} finally {
|
|
499
|
+
db.close();
|
|
500
|
+
}
|
|
501
|
+
// The read-only open leaves empty sidecars beside a WAL file; this file is
|
|
502
|
+
// private to this call, so they are removed to leave one file.
|
|
503
|
+
rmSync(`${path}-wal`, { force: true });
|
|
504
|
+
rmSync(`${path}-shm`, { force: true });
|
|
505
|
+
if (image?.digest !== expected.digest) return fail();
|
|
506
|
+
return {
|
|
507
|
+
path,
|
|
508
|
+
sha256: createHash("sha256").update(readFileSync(path)).digest("hex"),
|
|
509
|
+
sizeBytes: statSync(path).size,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Rules 1 to 4, in an order every foreign key accepts. The caller holds the transaction. */
|
|
514
|
+
function deleteRecords(db: WritableDatabase, preview: JourneyDeletionPreview): void {
|
|
515
|
+
const j = preview.journeyId;
|
|
516
|
+
const keys = deletedKeys(db, j);
|
|
517
|
+
const run = (sql: string, ...params: string[]) => db.prepare(sql).run(...params);
|
|
518
|
+
|
|
519
|
+
// Rule 2 before rule 1, for what the rows to delete still hold.
|
|
520
|
+
run(`DELETE FROM llm_calls WHERE conversation_id ${IN("?")}`, keys.conversations);
|
|
521
|
+
run(`DELETE FROM memory_access_log WHERE memory_id ${IN("?")}`, keys.memories);
|
|
522
|
+
run("DELETE FROM identity_integrations WHERE layer = 'journey' AND key = ?", j);
|
|
523
|
+
// Rule 3: an integration of another Journey keeps its place in the identity.
|
|
524
|
+
run(
|
|
525
|
+
`UPDATE identity_integrations SET journal_id = NULL WHERE journal_id ${IN("?")}`,
|
|
526
|
+
keys.memories,
|
|
527
|
+
);
|
|
528
|
+
run("DELETE FROM runtime_sessions WHERE journey = ?", j);
|
|
529
|
+
run("DELETE FROM memories WHERE journey = ?", j);
|
|
530
|
+
// Messages and embeddings go; what still points at the conversation is another Journey's, unlinked.
|
|
531
|
+
for (const conversationId of JSON.parse(keys.conversations) as string[]) {
|
|
532
|
+
deleteConversation(db, conversationId);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
run("DELETE FROM builder_refinement_cursors WHERE journey = ?", j);
|
|
536
|
+
run(
|
|
537
|
+
`UPDATE builder_refinement_cursors SET active_refinement_story_id = NULL WHERE active_refinement_story_id ${IN("?")}`,
|
|
538
|
+
keys.stories,
|
|
539
|
+
);
|
|
540
|
+
run(
|
|
541
|
+
`UPDATE builder_refinement_cursors SET active_change_request_id = NULL WHERE active_change_request_id ${IN("?")}`,
|
|
542
|
+
keys.changeRequests,
|
|
543
|
+
);
|
|
544
|
+
run("DELETE FROM builder_change_requests WHERE journey = ?", j);
|
|
545
|
+
run(
|
|
546
|
+
`UPDATE builder_change_requests SET refinement_story_id = NULL WHERE refinement_story_id ${IN("?")}`,
|
|
547
|
+
keys.stories,
|
|
548
|
+
);
|
|
549
|
+
run("DELETE FROM builder_refinement_stories WHERE journey = ?", j);
|
|
550
|
+
run("DELETE FROM exploratory_stories WHERE journey = ?", j);
|
|
551
|
+
run("DELETE FROM tasks WHERE journey = ?", j);
|
|
552
|
+
run("DELETE FROM attachments WHERE journey_id = ?", j);
|
|
553
|
+
|
|
554
|
+
run("DELETE FROM identity WHERE layer = 'journey_path' AND key = ?", j);
|
|
555
|
+
run("DELETE FROM identity WHERE layer = 'scene_orientation' AND key = 'journey:' || ?", j);
|
|
556
|
+
run("DELETE FROM identity WHERE layer = 'journey' AND key = ?", j);
|
|
557
|
+
|
|
558
|
+
// Rule 4. A row another row still holds is the extension's to release, not the core's.
|
|
559
|
+
for (const { table, columns } of extensionTables(db)) {
|
|
560
|
+
try {
|
|
561
|
+
run(`DELETE FROM "${table}" WHERE ${extensionWhere(columns)}`, ...columns.map(() => j));
|
|
562
|
+
} catch (error) {
|
|
563
|
+
if (error instanceof Error && /FOREIGN KEY constraint failed/.test(error.message)) {
|
|
564
|
+
reject("extension_rows_held", [table]);
|
|
565
|
+
}
|
|
566
|
+
throw error;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Delete `journeyId` and its records, as `previewJourneyDeletion` showed them, behind a named backup. */
|
|
572
|
+
export function deleteJourneyWithRecords(
|
|
573
|
+
dbPath: string,
|
|
574
|
+
request: { journeyId: string; expectedDigest: string },
|
|
575
|
+
): JourneyDeletionReceipt {
|
|
576
|
+
const journeyId = text(request?.journeyId);
|
|
577
|
+
const expectedDigest = text(request?.expectedDigest);
|
|
578
|
+
requireCurrent(dbPath);
|
|
579
|
+
const db = openLiveWriteDatabase(dbPath);
|
|
580
|
+
let backup: JourneyDeletionBackup | undefined;
|
|
581
|
+
let committed = false;
|
|
582
|
+
try {
|
|
583
|
+
const deleted = withTransaction(db, () => {
|
|
584
|
+
const preview = readPreview(db, journeyId);
|
|
585
|
+
if (preview.digest !== expectedDigest) reject("journey_changed");
|
|
586
|
+
if (preview.children.length > 0) reject("journey_has_children", preview.children);
|
|
587
|
+
backup = verifiedJourneyDeletionBackup(dbPath, preview);
|
|
588
|
+
deleteRecords(db, preview);
|
|
589
|
+
return preview;
|
|
590
|
+
});
|
|
591
|
+
committed = true;
|
|
592
|
+
if (!backup) throw new Error("journey deletion committed without a backup");
|
|
593
|
+
return { journeyId, tree: readTree(db), deleted, backup };
|
|
594
|
+
} catch (error) {
|
|
595
|
+
// Nothing was deleted: a backup of an unchanged home is only a large file. Once the
|
|
596
|
+
// deletion has committed, the backup is the only way back, whatever fails after it.
|
|
597
|
+
if (backup && !committed) removeBackup(backup.path);
|
|
598
|
+
throw error;
|
|
599
|
+
} finally {
|
|
600
|
+
db.close();
|
|
601
|
+
}
|
|
602
|
+
}
|