@gmickel/gno 1.30.7 → 1.32.0
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/README.md +6 -5
- package/assets/skill/SKILL.md +25 -0
- package/assets/skill/mcp-reference.md +6 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.30.7.zip → gno-browser-clipper-v1.32.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +19 -0
- package/spec/db/schema.sql +55 -0
- package/spec/mcp.md +176 -11
- package/spec/output-schemas/file-refactor-apply-result.schema.json +305 -0
- package/spec/output-schemas/file-refactor-preview.schema.json +393 -0
- package/spec/output-schemas/section-target-create-result.schema.json +20 -0
- package/spec/output-schemas/section-target-resolve-result.schema.json +194 -0
- package/spec/output-schemas/section-target.schema.json +118 -0
- package/spec/output-schemas/section.schema.json +113 -0
- package/src/core/document-capabilities.ts +13 -0
- package/src/core/file-ops.ts +129 -1
- package/src/core/file-refactor-adapter.ts +329 -0
- package/src/core/file-refactor-apply-edits.ts +61 -0
- package/src/core/file-refactor-apply-fs.ts +512 -0
- package/src/core/file-refactor-apply-safety.ts +340 -0
- package/src/core/file-refactor-apply-validate.ts +401 -0
- package/src/core/file-refactor-contract.ts +486 -0
- package/src/core/file-refactor-destination.ts +123 -0
- package/src/core/file-refactor-from-snapshot.ts +148 -0
- package/src/core/file-refactor-journal-port.ts +150 -0
- package/src/core/file-refactor-journal.ts +347 -0
- package/src/core/file-refactor-paths.ts +60 -0
- package/src/core/file-refactor-plan-classify.ts +208 -0
- package/src/core/file-refactor-plan-validate.ts +169 -0
- package/src/core/file-refactor-planner-types.ts +62 -0
- package/src/core/file-refactor-planner.ts +423 -0
- package/src/core/file-refactor-resolve.ts +280 -0
- package/src/core/file-refactor-service.ts +468 -0
- package/src/core/file-refactors.ts +84 -56
- package/src/core/link-destination-parse.ts +275 -0
- package/src/core/link-inventory-markdown.ts +454 -0
- package/src/core/link-inventory-opaque.ts +244 -0
- package/src/core/link-inventory-types.ts +47 -0
- package/src/core/link-inventory.ts +182 -0
- package/src/core/link-relevance.ts +150 -0
- package/src/core/section-parse.ts +187 -0
- package/src/core/section-target-link.ts +154 -0
- package/src/core/section-target-resolve.ts +351 -0
- package/src/core/section-target-transport.ts +519 -0
- package/src/core/section-target.ts +263 -0
- package/src/core/sections.ts +60 -115
- package/src/mcp/AGENTS.md +1 -0
- package/src/mcp/CLAUDE.md +1 -0
- package/src/mcp/http-egress.ts +1 -0
- package/src/mcp/tools/index.ts +37 -17
- package/src/mcp/tools/sections.ts +512 -0
- package/src/mcp/tools/workspace-write.ts +215 -97
- package/src/sdk/client.ts +238 -116
- package/src/sdk/index.ts +12 -0
- package/src/sdk/types.ts +61 -3
- package/src/serve/file-refactor-http.ts +239 -0
- package/src/serve/public/components/RefactorImpactPreview.tsx +227 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/lib/section-links.ts +189 -0
- package/src/serve/public/pages/DocView.tsx +395 -77
- package/src/serve/routes/api.ts +191 -104
- package/src/serve/routes/section-targets.ts +221 -0
- package/src/serve/server.ts +34 -0
- package/src/store/migrations/026-file-refactor-recovery-journal.ts +72 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +452 -0
- package/src/store/sqlite/file-refactor-journal-store.ts +275 -0
- package/src/store/types.ts +84 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.30.7.zip.sha256 +0 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe adapter from store resolution snapshot → planner input.
|
|
3
|
+
* Preserves truncation reasons so callers cannot drop them.
|
|
4
|
+
*
|
|
5
|
+
* @module src/core/file-refactor-from-snapshot
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FileRefactorOperation } from "./file-refactor-contract";
|
|
9
|
+
import type {
|
|
10
|
+
FileRefactorPlannerDocument,
|
|
11
|
+
PlanFileRefactorImpactInput,
|
|
12
|
+
} from "./file-refactor-planner-types";
|
|
13
|
+
|
|
14
|
+
import { planFileRefactorImpact } from "./file-refactor-planner";
|
|
15
|
+
|
|
16
|
+
/** Store-seam snapshot shape (structurally matches FileRefactorResolutionSnapshot). */
|
|
17
|
+
export interface FileRefactorSnapshotLike {
|
|
18
|
+
source: {
|
|
19
|
+
id: number;
|
|
20
|
+
uri: string;
|
|
21
|
+
relPath: string;
|
|
22
|
+
collection: string;
|
|
23
|
+
title: string | null;
|
|
24
|
+
content: string | null;
|
|
25
|
+
contentTruncated: boolean;
|
|
26
|
+
editable: boolean;
|
|
27
|
+
editableReason?: string;
|
|
28
|
+
};
|
|
29
|
+
catalog: Array<{
|
|
30
|
+
id: number;
|
|
31
|
+
uri: string;
|
|
32
|
+
relPath: string;
|
|
33
|
+
collection: string;
|
|
34
|
+
title: string | null;
|
|
35
|
+
}>;
|
|
36
|
+
referrers: Array<{
|
|
37
|
+
id: number;
|
|
38
|
+
uri: string;
|
|
39
|
+
relPath: string;
|
|
40
|
+
collection: string;
|
|
41
|
+
title: string | null;
|
|
42
|
+
content: string | null;
|
|
43
|
+
contentTruncated: boolean;
|
|
44
|
+
contentMissing: boolean;
|
|
45
|
+
editable: boolean;
|
|
46
|
+
editableReason?: string;
|
|
47
|
+
}>;
|
|
48
|
+
truncated: boolean;
|
|
49
|
+
truncationReasons: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function planInputFromResolutionSnapshot(input: {
|
|
53
|
+
operation: FileRefactorOperation;
|
|
54
|
+
snapshot: FileRefactorSnapshotLike;
|
|
55
|
+
target: {
|
|
56
|
+
uri: string;
|
|
57
|
+
relPath: string;
|
|
58
|
+
collection: string;
|
|
59
|
+
title?: string | null;
|
|
60
|
+
};
|
|
61
|
+
targetOccupied: boolean;
|
|
62
|
+
sourceEditable?: boolean;
|
|
63
|
+
}): PlanFileRefactorImpactInput {
|
|
64
|
+
const { snapshot } = input;
|
|
65
|
+
const truncationReasons = [...snapshot.truncationReasons];
|
|
66
|
+
if (snapshot.truncated && truncationReasons.length === 0) {
|
|
67
|
+
truncationReasons.push("snapshot_truncated");
|
|
68
|
+
}
|
|
69
|
+
// Defensive: legacy/custom snapshots may omit the reason while content is null.
|
|
70
|
+
if (snapshot.source.content === null) {
|
|
71
|
+
truncationReasons.push("source_content_missing");
|
|
72
|
+
}
|
|
73
|
+
if (snapshot.source.contentTruncated) {
|
|
74
|
+
truncationReasons.push("source_content_truncated");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const documents: FileRefactorPlannerDocument[] = snapshot.catalog.map(
|
|
78
|
+
(doc) => ({
|
|
79
|
+
id: doc.id,
|
|
80
|
+
uri: doc.uri,
|
|
81
|
+
relPath: doc.relPath,
|
|
82
|
+
collection: doc.collection,
|
|
83
|
+
title: doc.title,
|
|
84
|
+
active: true,
|
|
85
|
+
})
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const byUri = new Map(documents.map((doc) => [doc.uri, doc]));
|
|
89
|
+
for (const referrer of snapshot.referrers) {
|
|
90
|
+
let doc = byUri.get(referrer.uri);
|
|
91
|
+
if (!doc) {
|
|
92
|
+
doc = {
|
|
93
|
+
id: referrer.id,
|
|
94
|
+
uri: referrer.uri,
|
|
95
|
+
relPath: referrer.relPath,
|
|
96
|
+
collection: referrer.collection,
|
|
97
|
+
title: referrer.title,
|
|
98
|
+
active: true,
|
|
99
|
+
};
|
|
100
|
+
documents.push(doc);
|
|
101
|
+
byUri.set(doc.uri, doc);
|
|
102
|
+
}
|
|
103
|
+
doc.editable = referrer.editable;
|
|
104
|
+
doc.editableReason = referrer.editableReason;
|
|
105
|
+
if (referrer.contentMissing) {
|
|
106
|
+
doc.contentMissing = true;
|
|
107
|
+
doc.content = null;
|
|
108
|
+
truncationReasons.push("referrer_content_missing");
|
|
109
|
+
} else {
|
|
110
|
+
doc.content = referrer.content ?? null;
|
|
111
|
+
if (referrer.contentTruncated) {
|
|
112
|
+
truncationReasons.push("referrer_content_truncated");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
operation: input.operation,
|
|
119
|
+
source: {
|
|
120
|
+
uri: snapshot.source.uri,
|
|
121
|
+
relPath: snapshot.source.relPath,
|
|
122
|
+
collection: snapshot.source.collection,
|
|
123
|
+
title: snapshot.source.title,
|
|
124
|
+
content: snapshot.source.content ?? "",
|
|
125
|
+
editable: input.sourceEditable ?? snapshot.source.editable,
|
|
126
|
+
},
|
|
127
|
+
target: input.target,
|
|
128
|
+
documents,
|
|
129
|
+
targetOccupied: input.targetOccupied,
|
|
130
|
+
truncationReasons: [...new Set(truncationReasons)].sort(),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Plan impact directly from a store snapshot without dropping truncation. */
|
|
135
|
+
export async function planFileRefactorImpactFromSnapshot(input: {
|
|
136
|
+
operation: FileRefactorOperation;
|
|
137
|
+
snapshot: FileRefactorSnapshotLike;
|
|
138
|
+
target: {
|
|
139
|
+
uri: string;
|
|
140
|
+
relPath: string;
|
|
141
|
+
collection: string;
|
|
142
|
+
title?: string | null;
|
|
143
|
+
};
|
|
144
|
+
targetOccupied: boolean;
|
|
145
|
+
sourceEditable?: boolean;
|
|
146
|
+
}) {
|
|
147
|
+
return planFileRefactorImpact(planInputFromResolutionSnapshot(input));
|
|
148
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journal ports and helpers for reference-safe refactor apply.
|
|
3
|
+
*
|
|
4
|
+
* @module src/core/file-refactor-journal-port
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
FileRefactorJournalPort,
|
|
9
|
+
FileRefactorRecoveryReceipt,
|
|
10
|
+
} from "./file-refactor-journal";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
assertAllowedJournalPhaseTransition,
|
|
14
|
+
FILE_REFACTOR_JOURNAL_MAX_RECEIPTS,
|
|
15
|
+
FILE_REFACTOR_JOURNAL_PHASE_ORDINAL,
|
|
16
|
+
isPrunableJournalPhase,
|
|
17
|
+
} from "./file-refactor-journal";
|
|
18
|
+
|
|
19
|
+
function pruneOldestTerminalReceipts(
|
|
20
|
+
byId: Map<string, FileRefactorRecoveryReceipt>
|
|
21
|
+
): void {
|
|
22
|
+
while (byId.size >= FILE_REFACTOR_JOURNAL_MAX_RECEIPTS) {
|
|
23
|
+
const prunable = [...byId.values()]
|
|
24
|
+
.filter((row) => isPrunableJournalPhase(row.phase))
|
|
25
|
+
.sort((left, right) => {
|
|
26
|
+
if (left.updatedAtMs !== right.updatedAtMs) {
|
|
27
|
+
return left.updatedAtMs - right.updatedAtMs;
|
|
28
|
+
}
|
|
29
|
+
return left.journalId < right.journalId
|
|
30
|
+
? -1
|
|
31
|
+
: left.journalId > right.journalId
|
|
32
|
+
? 1
|
|
33
|
+
: 0;
|
|
34
|
+
});
|
|
35
|
+
const victim = prunable[0];
|
|
36
|
+
if (!victim) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
"File refactor journal at capacity with no prunable terminal receipts"
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
byId.delete(victim.journalId);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** In-memory journal port for focused unit tests. */
|
|
46
|
+
export function createMemoryFileRefactorJournal(): FileRefactorJournalPort {
|
|
47
|
+
const byId = new Map<string, FileRefactorRecoveryReceipt>();
|
|
48
|
+
return {
|
|
49
|
+
async createPreparedReceipt(draft) {
|
|
50
|
+
pruneOldestTerminalReceipts(byId);
|
|
51
|
+
const receipt: FileRefactorRecoveryReceipt = {
|
|
52
|
+
...draft,
|
|
53
|
+
phase: "prepared",
|
|
54
|
+
phaseOrdinal: FILE_REFACTOR_JOURNAL_PHASE_ORDINAL.prepared,
|
|
55
|
+
filesystemState: "unchanged",
|
|
56
|
+
indexState: "not_attempted",
|
|
57
|
+
updatedAtMs: draft.createdAtMs,
|
|
58
|
+
};
|
|
59
|
+
byId.set(receipt.journalId, receipt);
|
|
60
|
+
return receipt;
|
|
61
|
+
},
|
|
62
|
+
async advanceReceipt(journalId, update) {
|
|
63
|
+
const current = byId.get(journalId);
|
|
64
|
+
if (!current) throw new Error(`Missing journal ${journalId}`);
|
|
65
|
+
assertAllowedJournalPhaseTransition(current.phase, update.phase);
|
|
66
|
+
if (update.updatedAtMs < current.updatedAtMs) {
|
|
67
|
+
throw new Error("Journal updatedAtMs must be monotonic");
|
|
68
|
+
}
|
|
69
|
+
const next: FileRefactorRecoveryReceipt = {
|
|
70
|
+
...current,
|
|
71
|
+
phase: update.phase,
|
|
72
|
+
phaseOrdinal: FILE_REFACTOR_JOURNAL_PHASE_ORDINAL[update.phase],
|
|
73
|
+
filesystemState: update.filesystemState ?? current.filesystemState,
|
|
74
|
+
indexState: update.indexState ?? current.indexState,
|
|
75
|
+
fileEntries: update.fileEntries ?? current.fileEntries,
|
|
76
|
+
updatedAtMs: update.updatedAtMs,
|
|
77
|
+
};
|
|
78
|
+
byId.set(journalId, next);
|
|
79
|
+
return next;
|
|
80
|
+
},
|
|
81
|
+
async getReceiptById(journalId) {
|
|
82
|
+
return byId.get(journalId) ?? null;
|
|
83
|
+
},
|
|
84
|
+
async getLatestReceiptByPlanDigest(planDigest) {
|
|
85
|
+
const matches = [...byId.values()].filter(
|
|
86
|
+
(row) => row.planDigest === planDigest
|
|
87
|
+
);
|
|
88
|
+
matches.sort((left, right) => {
|
|
89
|
+
if (left.updatedAtMs !== right.updatedAtMs) {
|
|
90
|
+
return right.updatedAtMs - left.updatedAtMs;
|
|
91
|
+
}
|
|
92
|
+
return right.journalId < left.journalId
|
|
93
|
+
? -1
|
|
94
|
+
: right.journalId > left.journalId
|
|
95
|
+
? 1
|
|
96
|
+
: 0;
|
|
97
|
+
});
|
|
98
|
+
return matches[0] ?? null;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Adapter: wrap StorePort journal methods as FileRefactorJournalPort. */
|
|
104
|
+
export function journalPortFromStore(store: {
|
|
105
|
+
createFileRefactorPreparedReceipt: (
|
|
106
|
+
draft: Parameters<FileRefactorJournalPort["createPreparedReceipt"]>[0]
|
|
107
|
+
) => Promise<
|
|
108
|
+
{ ok: true; value: FileRefactorRecoveryReceipt } | { ok: false }
|
|
109
|
+
>;
|
|
110
|
+
advanceFileRefactorReceipt: (
|
|
111
|
+
journalId: string,
|
|
112
|
+
update: Parameters<FileRefactorJournalPort["advanceReceipt"]>[1]
|
|
113
|
+
) => Promise<
|
|
114
|
+
{ ok: true; value: FileRefactorRecoveryReceipt } | { ok: false }
|
|
115
|
+
>;
|
|
116
|
+
getFileRefactorReceiptById: (
|
|
117
|
+
journalId: string
|
|
118
|
+
) => Promise<
|
|
119
|
+
{ ok: true; value: FileRefactorRecoveryReceipt | null } | { ok: false }
|
|
120
|
+
>;
|
|
121
|
+
getLatestFileRefactorReceiptByPlanDigest: (
|
|
122
|
+
planDigest: string
|
|
123
|
+
) => Promise<
|
|
124
|
+
{ ok: true; value: FileRefactorRecoveryReceipt | null } | { ok: false }
|
|
125
|
+
>;
|
|
126
|
+
}): FileRefactorJournalPort {
|
|
127
|
+
return {
|
|
128
|
+
async createPreparedReceipt(draft) {
|
|
129
|
+
const result = await store.createFileRefactorPreparedReceipt(draft);
|
|
130
|
+
if (!result.ok) throw new Error("Failed to create refactor receipt");
|
|
131
|
+
return result.value;
|
|
132
|
+
},
|
|
133
|
+
async advanceReceipt(journalId, update) {
|
|
134
|
+
const result = await store.advanceFileRefactorReceipt(journalId, update);
|
|
135
|
+
if (!result.ok) throw new Error("Failed to advance refactor receipt");
|
|
136
|
+
return result.value;
|
|
137
|
+
},
|
|
138
|
+
async getReceiptById(journalId) {
|
|
139
|
+
const result = await store.getFileRefactorReceiptById(journalId);
|
|
140
|
+
if (!result.ok) throw new Error("Failed to load refactor receipt");
|
|
141
|
+
return result.value;
|
|
142
|
+
},
|
|
143
|
+
async getLatestReceiptByPlanDigest(planDigest) {
|
|
144
|
+
const result =
|
|
145
|
+
await store.getLatestFileRefactorReceiptByPlanDigest(planDigest);
|
|
146
|
+
if (!result.ok) throw new Error("Failed to lookup refactor receipt");
|
|
147
|
+
return result.value;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-free recovery journal types for reference-safe file refactors.
|
|
3
|
+
*
|
|
4
|
+
* Receipts never embed note bodies or replacement content — only IDs,
|
|
5
|
+
* digests, paths, phases, fingerprints, and status.
|
|
6
|
+
*
|
|
7
|
+
* Bounded globally: oldest terminal receipts are pruned on create
|
|
8
|
+
* (see FILE_REFACTOR_JOURNAL_MAX_RECEIPTS = 256). Uncertain/recovery-required
|
|
9
|
+
* receipts are never pruned.
|
|
10
|
+
*
|
|
11
|
+
* @module src/core/file-refactor-journal
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { FileRefactorOperation } from "./file-refactor-contract";
|
|
15
|
+
|
|
16
|
+
import { tryValidateRefactorArtifactRelPath } from "./file-refactor-apply-safety";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Ordered phases. Numeric ordinals make interruption state detectable:
|
|
20
|
+
* prepared < staging < committing < committed < sync_pending < converged.
|
|
21
|
+
* Rollback branch: committing → rolling_back → rolled_back | recovery_required.
|
|
22
|
+
* Abort before commit: prepared|staging → aborted.
|
|
23
|
+
*/
|
|
24
|
+
export const FILE_REFACTOR_JOURNAL_PHASES = [
|
|
25
|
+
"prepared",
|
|
26
|
+
"staging",
|
|
27
|
+
"committing",
|
|
28
|
+
"committed",
|
|
29
|
+
"sync_pending",
|
|
30
|
+
"converged",
|
|
31
|
+
"rolling_back",
|
|
32
|
+
"rolled_back",
|
|
33
|
+
"recovery_required",
|
|
34
|
+
"aborted",
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
export type FileRefactorJournalPhase =
|
|
38
|
+
(typeof FILE_REFACTOR_JOURNAL_PHASES)[number];
|
|
39
|
+
|
|
40
|
+
export const FILE_REFACTOR_JOURNAL_PHASE_ORDINAL: Record<
|
|
41
|
+
FileRefactorJournalPhase,
|
|
42
|
+
number
|
|
43
|
+
> = {
|
|
44
|
+
prepared: 10,
|
|
45
|
+
staging: 20,
|
|
46
|
+
committing: 30,
|
|
47
|
+
committed: 40,
|
|
48
|
+
sync_pending: 50,
|
|
49
|
+
converged: 60,
|
|
50
|
+
rolling_back: 35,
|
|
51
|
+
rolled_back: 70,
|
|
52
|
+
recovery_required: 80,
|
|
53
|
+
aborted: 5,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Hard cap: prune oldest terminal receipts only during create. */
|
|
57
|
+
export const FILE_REFACTOR_JOURNAL_MAX_RECEIPTS = 256;
|
|
58
|
+
|
|
59
|
+
export type FileRefactorJournalFileRole = "source" | "target" | "affected";
|
|
60
|
+
|
|
61
|
+
export type FileRefactorJournalFileStatus =
|
|
62
|
+
| "pending"
|
|
63
|
+
| "staged"
|
|
64
|
+
| "committed"
|
|
65
|
+
| "restored"
|
|
66
|
+
| "absent"
|
|
67
|
+
| "failed";
|
|
68
|
+
|
|
69
|
+
/** Per-file metadata only — fingerprints/status/artifact paths, never bodies. */
|
|
70
|
+
export interface FileRefactorJournalFileEntry {
|
|
71
|
+
role: FileRefactorJournalFileRole;
|
|
72
|
+
relPath: string;
|
|
73
|
+
/** Collection-relative stage artifact path (content-free locator). */
|
|
74
|
+
stageRelPath?: string;
|
|
75
|
+
/** Collection-relative backup artifact path (content-free locator). */
|
|
76
|
+
backupRelPath?: string;
|
|
77
|
+
originalFingerprint?: string;
|
|
78
|
+
expectedFingerprint?: string;
|
|
79
|
+
status: FileRefactorJournalFileStatus;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type FileRefactorJournalFilesystemState =
|
|
83
|
+
| "unchanged"
|
|
84
|
+
| "committed"
|
|
85
|
+
| "rolled_back"
|
|
86
|
+
| "recovery_required";
|
|
87
|
+
|
|
88
|
+
export type FileRefactorJournalIndexState =
|
|
89
|
+
| "not_attempted"
|
|
90
|
+
| "pending"
|
|
91
|
+
| "converged"
|
|
92
|
+
| "skipped";
|
|
93
|
+
|
|
94
|
+
/** Durable, content-free recovery receipt. */
|
|
95
|
+
export interface FileRefactorRecoveryReceipt {
|
|
96
|
+
journalId: string;
|
|
97
|
+
planDigest: string;
|
|
98
|
+
collection: string;
|
|
99
|
+
operation: FileRefactorOperation;
|
|
100
|
+
sourceRelPath: string;
|
|
101
|
+
targetRelPath: string;
|
|
102
|
+
phase: FileRefactorJournalPhase;
|
|
103
|
+
phaseOrdinal: number;
|
|
104
|
+
filesystemState: FileRefactorJournalFilesystemState;
|
|
105
|
+
indexState: FileRefactorJournalIndexState;
|
|
106
|
+
fileEntries: FileRefactorJournalFileEntry[];
|
|
107
|
+
createdAtMs: number;
|
|
108
|
+
updatedAtMs: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface FileRefactorRecoveryReceiptDraft {
|
|
112
|
+
journalId: string;
|
|
113
|
+
planDigest: string;
|
|
114
|
+
collection: string;
|
|
115
|
+
operation: FileRefactorOperation;
|
|
116
|
+
sourceRelPath: string;
|
|
117
|
+
targetRelPath: string;
|
|
118
|
+
fileEntries: FileRefactorJournalFileEntry[];
|
|
119
|
+
createdAtMs: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface FileRefactorJournalAdvance {
|
|
123
|
+
phase: FileRefactorJournalPhase;
|
|
124
|
+
filesystemState?: FileRefactorJournalFilesystemState;
|
|
125
|
+
indexState?: FileRefactorJournalIndexState;
|
|
126
|
+
fileEntries?: FileRefactorJournalFileEntry[];
|
|
127
|
+
updatedAtMs: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Small explicit port for durable recovery receipts.
|
|
132
|
+
* Prefer injecting this over a full StorePort in the apply service.
|
|
133
|
+
*/
|
|
134
|
+
export interface FileRefactorJournalPort {
|
|
135
|
+
createPreparedReceipt(
|
|
136
|
+
draft: FileRefactorRecoveryReceiptDraft
|
|
137
|
+
): Promise<FileRefactorRecoveryReceipt>;
|
|
138
|
+
advanceReceipt(
|
|
139
|
+
journalId: string,
|
|
140
|
+
update: FileRefactorJournalAdvance
|
|
141
|
+
): Promise<FileRefactorRecoveryReceipt>;
|
|
142
|
+
getReceiptById(
|
|
143
|
+
journalId: string
|
|
144
|
+
): Promise<FileRefactorRecoveryReceipt | null>;
|
|
145
|
+
/**
|
|
146
|
+
* Latest receipt for a plan digest (highest updatedAtMs, then journalId).
|
|
147
|
+
* Stable deterministic lookup for idempotent retry.
|
|
148
|
+
*/
|
|
149
|
+
getLatestReceiptByPlanDigest(
|
|
150
|
+
planDigest: string
|
|
151
|
+
): Promise<FileRefactorRecoveryReceipt | null>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Allowed phase transitions (monotonic / documented rollback branch).
|
|
156
|
+
* Same-phase advances persist per-file status during staging/commit/rollback.
|
|
157
|
+
*/
|
|
158
|
+
export const FILE_REFACTOR_JOURNAL_ALLOWED_TRANSITIONS: Record<
|
|
159
|
+
FileRefactorJournalPhase,
|
|
160
|
+
readonly FileRefactorJournalPhase[]
|
|
161
|
+
> = {
|
|
162
|
+
prepared: ["prepared", "staging", "aborted", "recovery_required"],
|
|
163
|
+
staging: [
|
|
164
|
+
"staging",
|
|
165
|
+
"committing",
|
|
166
|
+
"aborted",
|
|
167
|
+
"rolling_back",
|
|
168
|
+
"recovery_required",
|
|
169
|
+
],
|
|
170
|
+
committing: ["committing", "committed", "rolling_back", "recovery_required"],
|
|
171
|
+
committed: ["sync_pending", "converged"],
|
|
172
|
+
sync_pending: ["converged", "sync_pending"],
|
|
173
|
+
converged: [],
|
|
174
|
+
rolling_back: ["rolling_back", "rolled_back", "recovery_required"],
|
|
175
|
+
rolled_back: [],
|
|
176
|
+
recovery_required: [],
|
|
177
|
+
aborted: [],
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export function assertAllowedJournalPhaseTransition(
|
|
181
|
+
from: FileRefactorJournalPhase,
|
|
182
|
+
to: FileRefactorJournalPhase
|
|
183
|
+
): void {
|
|
184
|
+
const allowed = FILE_REFACTOR_JOURNAL_ALLOWED_TRANSITIONS[from];
|
|
185
|
+
if (!allowed.includes(to)) {
|
|
186
|
+
throw new Error(`Invalid journal phase transition ${from} -> ${to}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function isTerminalJournalPhase(
|
|
191
|
+
phase: FileRefactorJournalPhase
|
|
192
|
+
): boolean {
|
|
193
|
+
return (
|
|
194
|
+
phase === "converged" ||
|
|
195
|
+
phase === "rolled_back" ||
|
|
196
|
+
phase === "recovery_required" ||
|
|
197
|
+
phase === "aborted"
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Terminal phases safe to prune under the global retention cap. */
|
|
202
|
+
export function isPrunableJournalPhase(
|
|
203
|
+
phase: FileRefactorJournalPhase
|
|
204
|
+
): boolean {
|
|
205
|
+
return (
|
|
206
|
+
phase === "converged" || phase === "rolled_back" || phase === "aborted"
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function isUncertainJournalPhase(
|
|
211
|
+
phase: FileRefactorJournalPhase
|
|
212
|
+
): boolean {
|
|
213
|
+
return (
|
|
214
|
+
phase === "prepared" ||
|
|
215
|
+
phase === "staging" ||
|
|
216
|
+
phase === "committing" ||
|
|
217
|
+
phase === "rolling_back" ||
|
|
218
|
+
phase === "recovery_required"
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function isCommittedFilesystemPhase(
|
|
223
|
+
phase: FileRefactorJournalPhase
|
|
224
|
+
): boolean {
|
|
225
|
+
return (
|
|
226
|
+
phase === "committed" || phase === "sync_pending" || phase === "converged"
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const FILE_ENTRY_ROLES = new Set(["source", "target", "affected"]);
|
|
231
|
+
const FILE_ENTRY_STATUSES = new Set([
|
|
232
|
+
"pending",
|
|
233
|
+
"staged",
|
|
234
|
+
"committed",
|
|
235
|
+
"restored",
|
|
236
|
+
"absent",
|
|
237
|
+
"failed",
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
function isOptionalString(value: unknown): value is string | undefined {
|
|
241
|
+
return value === undefined || typeof value === "string";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function assertOptionalFingerprint(
|
|
245
|
+
value: unknown,
|
|
246
|
+
field: string
|
|
247
|
+
): asserts value is string | undefined {
|
|
248
|
+
if (value === undefined) return;
|
|
249
|
+
if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
|
|
250
|
+
throw new Error(`Corrupt file_entries_json: invalid ${field}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function assertOptionalArtifactRelPath(
|
|
255
|
+
value: unknown,
|
|
256
|
+
kind: "stage" | "backup"
|
|
257
|
+
): asserts value is string | undefined {
|
|
258
|
+
if (value === undefined) return;
|
|
259
|
+
if (typeof value !== "string") {
|
|
260
|
+
throw new Error(`Corrupt file_entries_json: invalid ${kind}RelPath`);
|
|
261
|
+
}
|
|
262
|
+
if (!tryValidateRefactorArtifactRelPath(value, kind)) {
|
|
263
|
+
throw new Error(`Corrupt file_entries_json: invalid ${kind}RelPath`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Defensive parse of file_entries_json — rejects corrupt shapes. */
|
|
268
|
+
export function parseFileRefactorJournalFileEntries(
|
|
269
|
+
raw: unknown
|
|
270
|
+
): FileRefactorJournalFileEntry[] {
|
|
271
|
+
if (typeof raw === "string") {
|
|
272
|
+
let parsed: unknown;
|
|
273
|
+
try {
|
|
274
|
+
parsed = JSON.parse(raw);
|
|
275
|
+
} catch {
|
|
276
|
+
throw new Error("Corrupt file_entries_json: invalid JSON");
|
|
277
|
+
}
|
|
278
|
+
return parseFileRefactorJournalFileEntries(parsed);
|
|
279
|
+
}
|
|
280
|
+
if (!Array.isArray(raw)) {
|
|
281
|
+
throw new Error("Corrupt file_entries_json: expected array");
|
|
282
|
+
}
|
|
283
|
+
const entries: FileRefactorJournalFileEntry[] = [];
|
|
284
|
+
const seenRelPaths = new Set<string>();
|
|
285
|
+
const seenRoles = new Set<string>();
|
|
286
|
+
for (const item of raw) {
|
|
287
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
288
|
+
throw new Error("Corrupt file_entries_json: entry must be object");
|
|
289
|
+
}
|
|
290
|
+
const row = item as Record<string, unknown>;
|
|
291
|
+
if (typeof row.role !== "string" || !FILE_ENTRY_ROLES.has(row.role)) {
|
|
292
|
+
throw new Error("Corrupt file_entries_json: invalid role");
|
|
293
|
+
}
|
|
294
|
+
if (typeof row.relPath !== "string" || row.relPath.length === 0) {
|
|
295
|
+
throw new Error("Corrupt file_entries_json: invalid relPath");
|
|
296
|
+
}
|
|
297
|
+
if (
|
|
298
|
+
typeof row.status !== "string" ||
|
|
299
|
+
!FILE_ENTRY_STATUSES.has(row.status)
|
|
300
|
+
) {
|
|
301
|
+
throw new Error("Corrupt file_entries_json: invalid status");
|
|
302
|
+
}
|
|
303
|
+
if (
|
|
304
|
+
!isOptionalString(row.stageRelPath) ||
|
|
305
|
+
!isOptionalString(row.backupRelPath) ||
|
|
306
|
+
!isOptionalString(row.originalFingerprint) ||
|
|
307
|
+
!isOptionalString(row.expectedFingerprint)
|
|
308
|
+
) {
|
|
309
|
+
throw new Error("Corrupt file_entries_json: invalid optional fields");
|
|
310
|
+
}
|
|
311
|
+
assertOptionalFingerprint(row.originalFingerprint, "originalFingerprint");
|
|
312
|
+
assertOptionalFingerprint(row.expectedFingerprint, "expectedFingerprint");
|
|
313
|
+
assertOptionalArtifactRelPath(row.stageRelPath, "stage");
|
|
314
|
+
assertOptionalArtifactRelPath(row.backupRelPath, "backup");
|
|
315
|
+
if (seenRelPaths.has(row.relPath)) {
|
|
316
|
+
throw new Error("Corrupt file_entries_json: duplicate relPath");
|
|
317
|
+
}
|
|
318
|
+
seenRelPaths.add(row.relPath);
|
|
319
|
+
const roleKey = `${row.role}:${row.relPath}`;
|
|
320
|
+
if (seenRoles.has(roleKey)) {
|
|
321
|
+
throw new Error("Corrupt file_entries_json: duplicate role/relPath");
|
|
322
|
+
}
|
|
323
|
+
seenRoles.add(roleKey);
|
|
324
|
+
if (
|
|
325
|
+
"content" in row ||
|
|
326
|
+
"body" in row ||
|
|
327
|
+
"replacement" in row ||
|
|
328
|
+
"originalDestination" in row ||
|
|
329
|
+
"finalContent" in row
|
|
330
|
+
) {
|
|
331
|
+
throw new Error("File refactor journal must not store note content");
|
|
332
|
+
}
|
|
333
|
+
entries.push({
|
|
334
|
+
role: row.role as FileRefactorJournalFileEntry["role"],
|
|
335
|
+
relPath: row.relPath,
|
|
336
|
+
stageRelPath: row.stageRelPath,
|
|
337
|
+
backupRelPath: row.backupRelPath,
|
|
338
|
+
originalFingerprint: row.originalFingerprint,
|
|
339
|
+
expectedFingerprint: row.expectedFingerprint,
|
|
340
|
+
status: row.status as FileRefactorJournalFileEntry["status"],
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return entries;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export const FILE_REFACTOR_SYNC_PENDING_INSTRUCTION =
|
|
347
|
+
"Filesystem refactor committed; retry apply with the same plan digest to finish index convergence without repeating file mutations.";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Browser-safe path target planning for canonical rename and move. */
|
|
2
|
+
|
|
3
|
+
// node:path/posix — no Bun path utils
|
|
4
|
+
import { posix as pathPosix } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { validateRelPath } from "./validation";
|
|
7
|
+
|
|
8
|
+
export interface RenamePlan {
|
|
9
|
+
nextRelPath: string;
|
|
10
|
+
nextUri: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface MovePlan {
|
|
14
|
+
nextRelPath: string;
|
|
15
|
+
nextUri: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function planRenameRefactor(input: {
|
|
19
|
+
collection: string;
|
|
20
|
+
currentRelPath: string;
|
|
21
|
+
nextName: string;
|
|
22
|
+
}): RenamePlan {
|
|
23
|
+
const current = validateRelPath(input.currentRelPath);
|
|
24
|
+
const directory = pathPosix.dirname(current);
|
|
25
|
+
const currentExt = pathPosix.extname(current);
|
|
26
|
+
const nextFilename = pathPosix.extname(input.nextName)
|
|
27
|
+
? input.nextName
|
|
28
|
+
: `${input.nextName}${currentExt}`;
|
|
29
|
+
const nextRelPath =
|
|
30
|
+
directory === "."
|
|
31
|
+
? validateRelPath(nextFilename)
|
|
32
|
+
: validateRelPath(`${directory}/${nextFilename}`);
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
nextRelPath,
|
|
36
|
+
nextUri: `gno://${input.collection}/${nextRelPath}`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function planMoveRefactor(input: {
|
|
41
|
+
collection: string;
|
|
42
|
+
currentRelPath: string;
|
|
43
|
+
folderPath: string;
|
|
44
|
+
nextName?: string;
|
|
45
|
+
}): MovePlan {
|
|
46
|
+
const current = validateRelPath(input.currentRelPath);
|
|
47
|
+
const safeFolder = validateRelPath(input.folderPath).replace(
|
|
48
|
+
/^\.\/|\/+$/g,
|
|
49
|
+
""
|
|
50
|
+
);
|
|
51
|
+
const filename = input.nextName?.trim() || pathPosix.basename(current);
|
|
52
|
+
const nextRelPath = safeFolder
|
|
53
|
+
? validateRelPath(`${safeFolder}/${filename}`)
|
|
54
|
+
: validateRelPath(filename);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
nextRelPath,
|
|
58
|
+
nextUri: `gno://${input.collection}/${nextRelPath}`,
|
|
59
|
+
};
|
|
60
|
+
}
|