mellos-mapping 0.22.1 → 0.24.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 +31 -4
- package/README.zh-CN.md +26 -4
- package/dist/hook-session-start.mjs +3 -1
- package/dist/mmap.mjs +1 -1
- package/dist/preview.mjs +41 -2
- package/dist/server.mjs +857 -412
- package/dist/store-paths.mjs +29 -1
- package/dist/terminal-worker.mjs +444 -300
- package/dist/watch.mjs +563 -339
- package/dist/web/app.css +106 -2
- package/dist/web/app.js +78 -0
- package/dist/web/index.html +1 -1
- package/dist/web/terminal.css +103 -0
- package/dist/web/terminal.html +1 -1
- package/dist/web/terminal.js +78 -0
- package/dist/web.mjs +314 -67
- package/docs/codex.md +7 -1
- package/docs/map-api.md +148 -0
- package/lib/domain/context.d.ts +11 -0
- package/lib/domain/context.js +27 -0
- package/lib/domain/text.js +11 -0
- package/lib/domain/types.d.ts +3 -0
- package/lib/store/format.js +18 -3
- package/lib/store/project.d.ts +2 -0
- package/lib/store/project.js +29 -0
- package/lib/store/store.d.ts +6 -1
- package/lib/store/store.js +6 -1
- package/lib/store/transaction.d.ts +12 -0
- package/lib/store/transaction.js +91 -0
- package/package.json +4 -2
- package/scripts/codex-register.mjs +1 -1
- package/scripts/mmap.mjs +1 -1
package/docs/map-api.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Persistent maps and the MCP API
|
|
2
|
+
|
|
3
|
+
Maps survive process restarts and new conversations. Begin with `mmap_read
|
|
4
|
+
{resource: "pages"}`, match the effort to a saved page, then read only relevant
|
|
5
|
+
records. A conversation is not a page identity. `mmap_view` remains the visual
|
|
6
|
+
representation; `mmap_read` is the editable data contract.
|
|
7
|
+
|
|
8
|
+
## Read
|
|
9
|
+
|
|
10
|
+
`mmap_read` returns JSON text and the same object in `structuredContent`:
|
|
11
|
+
`resource`, `project`, `page`, `revision`, `total`, `items`, `nextCursor`.
|
|
12
|
+
The default page is represented by a null `page` and the record ID `_default`.
|
|
13
|
+
Named page slugs and resource IDs remain stable; change display titles/labels
|
|
14
|
+
instead of renaming identity. Edge IDs are `from->to`.
|
|
15
|
+
|
|
16
|
+
| resource | Records |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| pages (default) | Page IDs, titles, kind, counts, context and each page's revision; a broken page is listed with its error |
|
|
19
|
+
| map | One page's metadata/context/counts; it does not embed the entire graph |
|
|
20
|
+
| nodes | Node IDs, labels, status and membership; request detail, evidence and sources through fields |
|
|
21
|
+
| edges | ID, from, to and optional label |
|
|
22
|
+
| layers / groups / lanes | The stored editable records |
|
|
23
|
+
| neighborhood | Related nodes selected by id/ids, direction (dependencies/consumers/both) and depth (0–4) |
|
|
24
|
+
| changes | Source-file verification state for selected nodes and up to 100 affected consumer IDs |
|
|
25
|
+
|
|
26
|
+
Use `id` for exact lookup (missing is `NOT_FOUND`), `ids` for a selection,
|
|
27
|
+
`query` for text matching, and status/layer/group/lane for node filtering.
|
|
28
|
+
`fields` projects records while always retaining identity and edge endpoints.
|
|
29
|
+
The default limit is 30, maximum 100. Repeat the same query with nextCursor;
|
|
30
|
+
if the graph changes, the cursor returns `CONFLICT` instead of skipping records.
|
|
31
|
+
An absent page is `NOT_FOUND`; an empty list is successful. The legacy view's
|
|
32
|
+
empty-map behavior is retained for compatibility.
|
|
33
|
+
|
|
34
|
+
Use a page or record response's revision for that page's next write. The top-level
|
|
35
|
+
revision of a pages listing describes the listing, not any individual page.
|
|
36
|
+
`ifRevision` can avoid resending unchanged graph data. It does not suppress
|
|
37
|
+
source checks: files may change without a graph edit. Pagination checks graph
|
|
38
|
+
revisions, not a filesystem snapshot of source files changing during the query.
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{"resource":"nodes","page":"payments","status":"in-progress","fields":["label","detail","evidence"],"limit":20}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Create, update and delete
|
|
45
|
+
|
|
46
|
+
The existing tools and fields remain supported. New writes return a revision
|
|
47
|
+
and structured outcome in addition to the existing summary.
|
|
48
|
+
|
|
49
|
+
- `mmap_declare`: create pages, layers, groups, lanes, nodes and edges. Existing
|
|
50
|
+
IDs are refused. Pass expectedRevision: "absent" to require a new page.
|
|
51
|
+
- `mmap_update`: existing node fields, layer names/ranks, group labels/layers,
|
|
52
|
+
lane labels, complete laneOrder, map title/kind/context and edge patches.
|
|
53
|
+
An edge patch identifies from/to, with optional label, newFrom and newTo.
|
|
54
|
+
A null label clears it. A laneOrder contains every lane exactly once.
|
|
55
|
+
- `mmap_remove`: existing per-resource deletion and cascades. Use deletePage:
|
|
56
|
+
true to delete the targeted page, including the default page. Inbound submap
|
|
57
|
+
references are refused unless references: "keep" is explicit. Unlink nodes
|
|
58
|
+
first when the references should disappear. This form cannot include edits
|
|
59
|
+
or legacy pages batches.
|
|
60
|
+
|
|
61
|
+
Optional fields are unchanged when omitted and removed when set to null where
|
|
62
|
+
the schema permits. context and sources are replaced as whole values. Moving
|
|
63
|
+
a group does not silently move members: include their intended node moves in
|
|
64
|
+
the same update or transaction. Normal single-field changes keep the old graph
|
|
65
|
+
constraints; use a batch for changes that require a coordinated final graph.
|
|
66
|
+
|
|
67
|
+
Every graph writer in the current MCP, HTTP viewer and watcher uses a cooperative
|
|
68
|
+
cross-process project lock. MCP expectedRevision is compared inside that lock
|
|
69
|
+
before loading the proposed changes into the saved map. `CONFLICT` requires a
|
|
70
|
+
fresh read and reconsideration of the edit. `BUSY` means another transaction is
|
|
71
|
+
active; retry after it completes. There is no background lock polling. Locks are
|
|
72
|
+
released on normal completion/error; a confirmed dead PID can be recovered.
|
|
73
|
+
An incomplete owner file after an abrupt crash is refused for manual inspection,
|
|
74
|
+
not guessed stale from its age.
|
|
75
|
+
|
|
76
|
+
Low-level library saveMapFile, hand edits, and older running processes do not
|
|
77
|
+
participate in this contract. Restart MCP processes and native watchers after
|
|
78
|
+
upgrading. Opening a Web surface upgrades services that lack format-2 support;
|
|
79
|
+
existing tabs must reconnect with the newly returned URL. Atomic file
|
|
80
|
+
replacement alone does not make a caller's stale read/modify/write safe.
|
|
81
|
+
|
|
82
|
+
Legacy `mmap_remove {pages:[...]}` remains a separately documented batch of file
|
|
83
|
+
deletions, with explicit partial success and historical reference behavior. It
|
|
84
|
+
does not accept expectedRevision; use per-page deletePage for checked deletion.
|
|
85
|
+
It is not a multi-page transaction.
|
|
86
|
+
|
|
87
|
+
## One-page mixed transactions
|
|
88
|
+
|
|
89
|
+
`mmap_batch` accepts page, expectedRevision and 1–100 ordered operations. Each
|
|
90
|
+
operation is `{op: "declare" | "update" | "remove", data: {...}}`, with the
|
|
91
|
+
same per-page fields as that tool. Per-operation page, expectedRevision and
|
|
92
|
+
page deletion are excluded. Changes are drafted, the final graph is validated,
|
|
93
|
+
and the page is saved once; a refusal preserves the original file bytes.
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"page":"payments",
|
|
98
|
+
"expectedRevision":"<revision returned by mmap_read>",
|
|
99
|
+
"operations":[
|
|
100
|
+
{"op":"remove","data":{"edges":[{"from":"api","to":"old-store"}]}},
|
|
101
|
+
{"op":"declare","data":{"nodes":[{"id":"new-store","label":"Store","layer":"base"}]}},
|
|
102
|
+
{"op":"declare","data":{"edges":[{"from":"api","to":"new-store"}]}},
|
|
103
|
+
{"op":"update","data":{"context":{"summary":"Payment services","next":"Verify the new store contract"}}}
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Machine-readable error codes include NOT_FOUND, INVALID_STORE, REFUSED,
|
|
109
|
+
CONFLICT, BUSY, INVALID_CURSOR, INVALID_ARGUMENT, REFERENCED and SAVE_FAILED.
|
|
110
|
+
Malformed schema inputs remain MCP invalid-argument errors. Read calls do not
|
|
111
|
+
open panels or create page files.
|
|
112
|
+
|
|
113
|
+
## Checkpoints and source changes
|
|
114
|
+
|
|
115
|
+
Map context has optional summary and next fields (up to 2,000 characters each).
|
|
116
|
+
Save concise decisions and next actions, rather than copying the conversation.
|
|
117
|
+
Nodes can hold up to 100 sources: `{path: "src/store.ts", sha256: "..."}`.
|
|
118
|
+
Paths are project-relative, forward-slash paths with no traversal. SHA256 is
|
|
119
|
+
optional so sources can be linked before verification.
|
|
120
|
+
|
|
121
|
+
`mmap_read {resource:"changes", page, id}` hashes only the selected node's
|
|
122
|
+
listed files, reports currentSha256 and unchanged/changed/unknown node state,
|
|
123
|
+
and identifies affected consumers. Missing files count as changed. Missing
|
|
124
|
+
baselines, inaccessible files, files over 8 MiB and paths resolving outside
|
|
125
|
+
the project remain unverified. There is no whole-repository source scan and
|
|
126
|
+
no automatic status change. After verification, copy the current hashes into
|
|
127
|
+
sources[].sha256 with a revision-checked update.
|
|
128
|
+
|
|
129
|
+
Classic maps continue to serialize as format 1. Maps containing context or
|
|
130
|
+
source references serialize as format 2; this runtime reads both. Older runtimes
|
|
131
|
+
must refuse format 2 instead of silently dropping its new fields. Removing all
|
|
132
|
+
extension fields allows serialization as format 1 again. Both formats preserve
|
|
133
|
+
the same graph, statuses and existing evidence.
|
|
134
|
+
|
|
135
|
+
## Project identity
|
|
136
|
+
|
|
137
|
+
Without an explicit MELLOS_MAPPING_CWD or CLAUDE_PROJECT_DIR, the server walks
|
|
138
|
+
up from cwd to the nearest existing map store or Git root. An explicit override
|
|
139
|
+
stays explicit. The mmap command and watcher use the same resolver. Nested
|
|
140
|
+
repositories and worktrees are boundaries; separate branches are not silently
|
|
141
|
+
redirected to a shared writable graph. Carry maps through Git or deliberate
|
|
142
|
+
workspace setup when a new worktree needs them, and recheck source baselines.
|
|
143
|
+
|
|
144
|
+
Codex CLI and App skills both start by reading existing pages. MCP initialization
|
|
145
|
+
also advertises the restore workflow, including for MCP-only installations.
|
|
146
|
+
Viewer selection follows host capabilities; the CLI does not try to use a
|
|
147
|
+
desktop panel tool. After context compaction, repeat the lightweight page/context
|
|
148
|
+
read, then load only the affected resources.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Optional portable provenance; no host paths or I/O in the map format. */
|
|
2
|
+
export interface SourceRef {
|
|
3
|
+
readonly path: string;
|
|
4
|
+
readonly sha256?: string | undefined;
|
|
5
|
+
}
|
|
6
|
+
export interface MapContext {
|
|
7
|
+
readonly summary?: string | undefined;
|
|
8
|
+
readonly next?: string | undefined;
|
|
9
|
+
}
|
|
10
|
+
export declare function sourceError(raw: unknown): string | undefined;
|
|
11
|
+
export declare function contextError(raw: unknown): string | undefined;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function sourceError(raw) {
|
|
2
|
+
if (!Array.isArray(raw) || raw.length > 100)
|
|
3
|
+
return 'sources must be an array of at most 100 file references';
|
|
4
|
+
for (const item of raw) {
|
|
5
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
6
|
+
return 'source must be an object';
|
|
7
|
+
const s = item;
|
|
8
|
+
if (Object.keys(s).some(k => k !== 'path' && k !== 'sha256'))
|
|
9
|
+
return 'unknown source field';
|
|
10
|
+
if (typeof s.path !== 'string' || s.path.length > 1024 || !s.path || /[\u0000-\u001f\u007f-\u009f\\:]/.test(s.path) || s.path.startsWith('/') || s.path.split('/').some(p => !p || p === '.' || p === '..'))
|
|
11
|
+
return 'source path must be relative to the project, with forward slashes and no traversal';
|
|
12
|
+
if (s.sha256 !== undefined && (typeof s.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(s.sha256)))
|
|
13
|
+
return 'source sha256 must be a lowercase SHA256 hash';
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
export function contextError(raw) {
|
|
18
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
19
|
+
return 'context must be an object';
|
|
20
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
21
|
+
if (key !== 'summary' && key !== 'next')
|
|
22
|
+
return 'unknown context field';
|
|
23
|
+
if (typeof value !== 'string' || value.length > 2000 || /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/.test(value))
|
|
24
|
+
return 'context fields must be text of at most 2000 characters';
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
package/lib/domain/text.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
import { contextError, sourceError } from './context.js';
|
|
1
2
|
// Explicit ranges also work in the JSON Schema published by the MCP adapter.
|
|
2
3
|
export const NO_CONTROLS = /^[^\u0000-\u001f\u007f-\u009f]*$/;
|
|
3
4
|
export const NO_CONTROLS_TEXT = 'one line of text; control characters (ESC, newline, tab) are not allowed';
|
|
4
5
|
export const NO_CONTROLS_BUT_BREAKS = /^[^\u0000-\u0008\u000b-\u001f\u007f-\u009f]*$/;
|
|
5
6
|
export const NO_CONTROLS_BUT_BREAKS_TEXT = 'text with optional newlines (\\n) and tabs; other control characters (ESC, BEL, CR) are not allowed';
|
|
6
7
|
export function mapTextError(map) {
|
|
8
|
+
if (map.context !== undefined) {
|
|
9
|
+
const error = contextError(map.context);
|
|
10
|
+
if (error)
|
|
11
|
+
return error;
|
|
12
|
+
}
|
|
7
13
|
const check = (field, value, multiline = false) => value === undefined || (multiline ? NO_CONTROLS_BUT_BREAKS : NO_CONTROLS).test(value)
|
|
8
14
|
? undefined : `${field}: ${multiline ? NO_CONTROLS_BUT_BREAKS_TEXT : NO_CONTROLS_TEXT}`;
|
|
9
15
|
let error = check('title', map.title);
|
|
@@ -22,6 +28,11 @@ export function mapTextError(map) {
|
|
|
22
28
|
}
|
|
23
29
|
}
|
|
24
30
|
for (const [i, node] of map.nodes.entries()) {
|
|
31
|
+
if (node.sources !== undefined) {
|
|
32
|
+
const error = sourceError(node.sources);
|
|
33
|
+
if (error)
|
|
34
|
+
return `nodes[${i}]: ${error}`;
|
|
35
|
+
}
|
|
25
36
|
for (const name of ['label', 'evidence', 'detail']) {
|
|
26
37
|
// Version-1 files and document exports also support multiline evidence.
|
|
27
38
|
// MCP keeps its narrower one-line evidence input for concise updates.
|
package/lib/domain/types.d.ts
CHANGED
|
@@ -171,7 +171,9 @@ export interface MapLane {
|
|
|
171
171
|
readonly label: string;
|
|
172
172
|
}
|
|
173
173
|
/** A unit of work living in exactly one band. */
|
|
174
|
+
export type { SourceRef, MapContext } from './context.js';
|
|
174
175
|
export interface MapNode {
|
|
176
|
+
readonly sources?: readonly import('./context.js').SourceRef[];
|
|
175
177
|
readonly id: NodeId;
|
|
176
178
|
readonly label: string;
|
|
177
179
|
readonly layer: LayerId;
|
|
@@ -198,6 +200,7 @@ export interface DepEdge {
|
|
|
198
200
|
}
|
|
199
201
|
/** The whole map. A plain immutable value — operations return new maps. */
|
|
200
202
|
export interface MellosMap {
|
|
203
|
+
readonly context?: import('./context.js').MapContext;
|
|
201
204
|
readonly title?: string;
|
|
202
205
|
/** Presentation intent; absent means 'dev' (the progress ledger). */
|
|
203
206
|
readonly kind?: MapKind;
|
package/lib/store/format.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { declareGroup, declareLane, declareLayer, declareNode, linkNodes, setKind, setTitle, updateNode } from '../domain/ops.js';
|
|
24
24
|
import { mapTextError } from '../domain/text.js';
|
|
25
|
+
import { sourceError, contextError } from '../domain/context.js';
|
|
25
26
|
import { EMPTY_MAP, ID_RULE, ID_RULE_TEXT, describeMapError, err, makeGroupId, makeLaneId, makeLayerId, makeMapKind, makeNodeId, makeNodeKind, makeNodeStatus, makeRank, makeSubmapRef, ok, } from '../domain/types.js';
|
|
26
27
|
/** On-disk format version. Bump only with a documented migration. */
|
|
27
28
|
export const STATE_FILE_VERSION = 1;
|
|
@@ -99,8 +100,8 @@ function optionalString(rec, key, where, path) {
|
|
|
99
100
|
export function parseMap(raw, path) {
|
|
100
101
|
if (!isRecord(raw))
|
|
101
102
|
return err({ kind: 'bad-shape', path, detail: 'root is not an object' });
|
|
102
|
-
if (raw['version'] !== STATE_FILE_VERSION) {
|
|
103
|
-
return err({ kind: 'bad-shape', path, detail: `version is ${String(raw['version'])}, expected ${STATE_FILE_VERSION}` });
|
|
103
|
+
if (raw['version'] !== STATE_FILE_VERSION && raw['version'] !== 2) {
|
|
104
|
+
return err({ kind: 'bad-shape', path, detail: `version is ${String(raw['version'])}, expected ${STATE_FILE_VERSION} or 2` });
|
|
104
105
|
}
|
|
105
106
|
const layers = arrayField(raw, 'layers', path, 'required');
|
|
106
107
|
if (!layers.ok)
|
|
@@ -118,6 +119,12 @@ export function parseMap(raw, path) {
|
|
|
118
119
|
if (!groups.ok)
|
|
119
120
|
return groups;
|
|
120
121
|
let map = EMPTY_MAP;
|
|
122
|
+
if (raw['context'] !== undefined) {
|
|
123
|
+
const error = contextError(raw['context']);
|
|
124
|
+
if (error)
|
|
125
|
+
return err({ kind: 'bad-shape', path, detail: error });
|
|
126
|
+
map = { ...map, context: raw['context'] };
|
|
127
|
+
}
|
|
121
128
|
const title = optionalString(raw, 'title', 'map', path);
|
|
122
129
|
if (!title.ok)
|
|
123
130
|
return title;
|
|
@@ -292,6 +299,12 @@ export function parseMap(raw, path) {
|
|
|
292
299
|
return err({ kind: 'invariant-violation', path, violation: updated.error });
|
|
293
300
|
map = updated.value;
|
|
294
301
|
}
|
|
302
|
+
if (rawNode['sources'] !== undefined) {
|
|
303
|
+
const error = sourceError(rawNode['sources']);
|
|
304
|
+
if (error)
|
|
305
|
+
return err({ kind: 'bad-shape', path, detail: `${where}: ${error}` });
|
|
306
|
+
map = { ...map, nodes: map.nodes.map(n => n.id === id.value ? { ...n, sources: rawNode['sources'] } : n) };
|
|
307
|
+
}
|
|
295
308
|
}
|
|
296
309
|
for (const [i, rawEdge] of edges.value.entries()) {
|
|
297
310
|
const where = `edges[${i}]`;
|
|
@@ -323,7 +336,9 @@ export function parseMap(raw, path) {
|
|
|
323
336
|
/** Serialize a map into the on-disk shape. Inverse of parseMap for valid maps (F1). */
|
|
324
337
|
export function serializeMap(map) {
|
|
325
338
|
const body = {
|
|
326
|
-
|
|
339
|
+
// Older runtimes must refuse maps with provenance rather than silently erasing it.
|
|
340
|
+
version: map.context !== undefined || map.nodes.some(n => n.sources !== undefined) ? 2 : STATE_FILE_VERSION,
|
|
341
|
+
...(map.context !== undefined ? { context: map.context } : {}),
|
|
327
342
|
...(map.title !== undefined ? { title: map.title } : {}),
|
|
328
343
|
...(map.kind !== undefined ? { kind: map.kind } : {}),
|
|
329
344
|
layers: map.layers,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
4
|
+
/** Nearest existing store or Git root; never cross a nested repository/worktree boundary. */
|
|
5
|
+
export function resolveProjectDirectory(cwd, stopAt = [homedir(), tmpdir()]) {
|
|
6
|
+
let start = resolve(cwd);
|
|
7
|
+
try {
|
|
8
|
+
start = realpathSync(start);
|
|
9
|
+
}
|
|
10
|
+
catch { /* Explicit test/manual paths may not exist yet. */ }
|
|
11
|
+
let dir = start;
|
|
12
|
+
const boundaries = new Set(stopAt.map(path => { try {
|
|
13
|
+
return realpathSync(path);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return resolve(path);
|
|
17
|
+
} }));
|
|
18
|
+
while (true) {
|
|
19
|
+
// A user's global .mellos directory is not an ancestor project's store.
|
|
20
|
+
if (dir !== start && boundaries.has(dir))
|
|
21
|
+
return start;
|
|
22
|
+
if (existsSync(join(dir, '.git')) || existsSync(join(dir, '.mellos', 'map.json')) || existsSync(join(dir, '.mellos', 'pages')))
|
|
23
|
+
return dir;
|
|
24
|
+
const parent = dirname(dir);
|
|
25
|
+
if (parent === dir)
|
|
26
|
+
return start;
|
|
27
|
+
dir = parent;
|
|
28
|
+
}
|
|
29
|
+
}
|
package/lib/store/store.d.ts
CHANGED
|
@@ -16,10 +16,13 @@
|
|
|
16
16
|
* The concurrency model P2 buys, stated plainly:
|
|
17
17
|
* - Several writers may target one project at once. Each save is atomic and
|
|
18
18
|
* lands whole, so a reader never sees half a map — but there is NO
|
|
19
|
-
* lost-update protection
|
|
19
|
+
* lost-update protection in this low-level save API: two saves race, and the last
|
|
20
20
|
* rename wins, silently discarding what the other writer computed from an
|
|
21
21
|
* older read. Pages are the isolation unit (one effort = one page); two
|
|
22
22
|
* sessions that must not clobber each other belong on two pages.
|
|
23
|
+
* Current MCP/viewer writers additionally use transaction.ts to lock the
|
|
24
|
+
* complete read/modify/write operation, with optional revision checks.
|
|
25
|
+
* Direct library saves and older processes do not acquire that lock.
|
|
23
26
|
* - The temp file carries the writer's pid and a random suffix, so
|
|
24
27
|
* concurrent writers never share one and never install each other's
|
|
25
28
|
* half-written content.
|
|
@@ -47,3 +50,5 @@ export { VIEWERS_DIR_NAME, VIEWER_FILE_VERSION, VIEWER_HEARTBEAT_MS, VIEWER_STAL
|
|
|
47
50
|
export { CONFIG_FILE_NAME, CONFIG_FILE_VERSION, configFilePath, userConfigFilePath, MAPPING_POLICIES, type MappingPolicy, type InvalidPolicy, makeMappingPolicy, describeMappingPolicy, loadMappingPolicy, saveMappingPolicy, POLICY_SCOPES, type PolicyScope, type MappingPolicyScopes, effectiveMappingPolicy } from './policy.js';
|
|
48
51
|
export { LEGACY_STATE_FILE_RELATIVE_PATH, migrateLegacyStore } from './migration.js';
|
|
49
52
|
export { loadMapFile, saveMapFile } from './maps.js';
|
|
53
|
+
export { withStoreLock, revisionOf, assertRevision, LedgerError } from './transaction.js';
|
|
54
|
+
export { resolveProjectDirectory } from './project.js';
|
package/lib/store/store.js
CHANGED
|
@@ -16,10 +16,13 @@
|
|
|
16
16
|
* The concurrency model P2 buys, stated plainly:
|
|
17
17
|
* - Several writers may target one project at once. Each save is atomic and
|
|
18
18
|
* lands whole, so a reader never sees half a map — but there is NO
|
|
19
|
-
* lost-update protection
|
|
19
|
+
* lost-update protection in this low-level save API: two saves race, and the last
|
|
20
20
|
* rename wins, silently discarding what the other writer computed from an
|
|
21
21
|
* older read. Pages are the isolation unit (one effort = one page); two
|
|
22
22
|
* sessions that must not clobber each other belong on two pages.
|
|
23
|
+
* Current MCP/viewer writers additionally use transaction.ts to lock the
|
|
24
|
+
* complete read/modify/write operation, with optional revision checks.
|
|
25
|
+
* Direct library saves and older processes do not acquire that lock.
|
|
23
26
|
* - The temp file carries the writer's pid and a random suffix, so
|
|
24
27
|
* concurrent writers never share one and never install each other's
|
|
25
28
|
* half-written content.
|
|
@@ -48,3 +51,5 @@ export { VIEWERS_DIR_NAME, VIEWER_FILE_VERSION, VIEWER_HEARTBEAT_MS, VIEWER_STAL
|
|
|
48
51
|
export { CONFIG_FILE_NAME, CONFIG_FILE_VERSION, configFilePath, userConfigFilePath, MAPPING_POLICIES, makeMappingPolicy, describeMappingPolicy, loadMappingPolicy, saveMappingPolicy, POLICY_SCOPES, effectiveMappingPolicy } from './policy.js';
|
|
49
52
|
export { LEGACY_STATE_FILE_RELATIVE_PATH, migrateLegacyStore } from './migration.js';
|
|
50
53
|
export { loadMapFile, saveMapFile } from './maps.js';
|
|
54
|
+
export { withStoreLock, revisionOf, assertRevision, LedgerError } from './transaction.js';
|
|
55
|
+
export { resolveProjectDirectory } from './project.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { MellosMap } from '../domain/types.js';
|
|
2
|
+
export declare class LedgerError extends Error {
|
|
3
|
+
readonly code: string;
|
|
4
|
+
readonly details: Record<string, unknown>;
|
|
5
|
+
constructor(code: string, message: string, details?: Record<string, unknown>);
|
|
6
|
+
}
|
|
7
|
+
export declare const revisionOf: (map: MellosMap) => string;
|
|
8
|
+
export declare function assertRevision(actual: string, expected?: string): void;
|
|
9
|
+
/** A named page and the default page share the same project lock. */
|
|
10
|
+
export declare function storeDirectory(file: string): string;
|
|
11
|
+
/** No timed polling: contention is an explicit retryable BUSY result. Never steal a live lock. */
|
|
12
|
+
export declare function withStoreLock<T>(file: string, action: () => T): T;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** Cooperative, cross-process transactions for MCP and HTTP writers. */
|
|
2
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
5
|
+
import { serializeMap } from './format.js';
|
|
6
|
+
export class LedgerError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
details;
|
|
9
|
+
constructor(code, message, details = {}) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.details = details;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export const revisionOf = (map) => createHash('sha256').update(serializeMap(map)).digest('hex');
|
|
16
|
+
export function assertRevision(actual, expected) {
|
|
17
|
+
if (expected !== undefined && expected !== actual)
|
|
18
|
+
throw new LedgerError('CONFLICT', 'Map changed; read the current revision before retrying.', { expectedRevision: expected, actualRevision: actual });
|
|
19
|
+
}
|
|
20
|
+
/** A named page and the default page share the same project lock. */
|
|
21
|
+
export function storeDirectory(file) {
|
|
22
|
+
const dir = dirname(file);
|
|
23
|
+
return dir.endsWith('/pages') || dir.endsWith('\\pages') ? dirname(dir) : dir;
|
|
24
|
+
}
|
|
25
|
+
function deadOwner(lock) {
|
|
26
|
+
try {
|
|
27
|
+
const owner = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'));
|
|
28
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0)
|
|
29
|
+
return false;
|
|
30
|
+
try {
|
|
31
|
+
process.kill(owner.pid, 0);
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
return e.code === 'ESRCH';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** No timed polling: contention is an explicit retryable BUSY result. Never steal a live lock. */
|
|
43
|
+
export function withStoreLock(file, action) {
|
|
44
|
+
const dir = storeDirectory(file);
|
|
45
|
+
mkdirSync(dir, { recursive: true });
|
|
46
|
+
const lock = join(dir, '.write-lock');
|
|
47
|
+
const owner = join(lock, 'owner.json');
|
|
48
|
+
const token = randomUUID();
|
|
49
|
+
try {
|
|
50
|
+
mkdirSync(lock);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error.code !== 'EEXIST')
|
|
54
|
+
throw error;
|
|
55
|
+
// Reapers serialize inside the old directory and recheck its owner after acquiring.
|
|
56
|
+
// Missing/invalid owners are deliberately not reclaimed on an age heuristic.
|
|
57
|
+
if (!deadOwner(lock))
|
|
58
|
+
throw new LedgerError('BUSY', `Another writer owns ${lock}; retry after it completes. An orphan without owner metadata needs manual inspection.`);
|
|
59
|
+
try {
|
|
60
|
+
mkdirSync(join(lock, '.reap'));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new LedgerError('BUSY', 'Another process is recovering the writer lock.');
|
|
64
|
+
}
|
|
65
|
+
if (!deadOwner(lock)) {
|
|
66
|
+
rmSync(join(lock, '.reap'), { recursive: true, force: true });
|
|
67
|
+
throw new LedgerError('BUSY', 'Writer ownership changed.');
|
|
68
|
+
}
|
|
69
|
+
rmSync(lock, { recursive: true });
|
|
70
|
+
try {
|
|
71
|
+
mkdirSync(lock);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new LedgerError('BUSY', 'Another writer acquired the recovered lock.');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
let initialized = false;
|
|
78
|
+
try {
|
|
79
|
+
writeFileSync(owner, JSON.stringify({ pid: process.pid, token }), { flag: 'wx' });
|
|
80
|
+
initialized = true;
|
|
81
|
+
return action();
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
// Only remove the directory still owned by this invocation.
|
|
85
|
+
try {
|
|
86
|
+
if (!initialized || JSON.parse(readFileSync(owner, 'utf8')).token === token)
|
|
87
|
+
rmSync(lock, { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
catch { /* A cleanup error must not misreport a successfully committed map as unsaved. */ }
|
|
90
|
+
}
|
|
91
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mellos-mapping",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"mcpName": "io.github.GuangminJu/mellos-mapping",
|
|
5
5
|
"description": "A live layered dependency map for bottom-up development — MCP server + terminal pane. Ghost the design first, then light nodes up from the bottom as they are built and verified.",
|
|
6
6
|
"type": "module",
|
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
"lib",
|
|
71
71
|
"README.zh-CN.md",
|
|
72
72
|
"docs/codex.md",
|
|
73
|
+
"docs/map-api.md",
|
|
73
74
|
"scripts/codex-register.mjs",
|
|
74
75
|
"scripts/codex-cli.mjs",
|
|
75
76
|
"scripts/install-mmap-command.mjs",
|
|
@@ -92,9 +93,10 @@
|
|
|
92
93
|
"check:release": "node scripts/check-release.mjs",
|
|
93
94
|
"check:package": "node scripts/check-package-surface.mjs",
|
|
94
95
|
"check:codex": "node scripts/check-codex-package.mjs",
|
|
96
|
+
"check:reuse": "node scripts/check-reuse.mjs",
|
|
95
97
|
"benchmark:render": "node scripts/benchmark-render.mjs",
|
|
96
98
|
"prepack": "npm run build",
|
|
97
|
-
"verify": "npm run typecheck && npm run test && npm run build && npm run check:package && npm run check:codex && npm run check:release"
|
|
99
|
+
"verify": "npm run typecheck && npm run test && npm run build && npm run check:reuse && npm run check:package && npm run check:codex && npm run check:release"
|
|
98
100
|
},
|
|
99
101
|
"devDependencies": {
|
|
100
102
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
@@ -42,7 +42,7 @@ if (launchedAsEntry(import.meta.url)) {
|
|
|
42
42
|
const installed = registerServer(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
43
43
|
console.log(`mellos-mapping MCP registered with Codex: ${installed.nodePath} ${installed.serverPath}`);
|
|
44
44
|
console.log('State files resolve to each session’s working directory (.mellos/map.json).');
|
|
45
|
-
console.log('Start a new Codex conversation to load the skills and
|
|
45
|
+
console.log('Start a new Codex conversation to load the skills and eight mmap tools.');
|
|
46
46
|
} catch (error) {
|
|
47
47
|
console.error(error instanceof Error ? error.message : String(error));
|
|
48
48
|
process.exitCode = 1;
|
package/scripts/mmap.mjs
CHANGED
|
@@ -170,7 +170,7 @@ async function main() {
|
|
|
170
170
|
console.error(parsed.error);
|
|
171
171
|
process.exit(1);
|
|
172
172
|
}
|
|
173
|
-
const candidates =
|
|
173
|
+
const candidates = [store.resolveProjectDirectory(process.cwd())];
|
|
174
174
|
const marker = storeMarkerOf(store.STATE_FILE_RELATIVE_PATH);
|
|
175
175
|
const project = nearestProject(candidates, candidates.map((dir) => existsSync(join(dir, marker))));
|
|
176
176
|
const cfg = { ...parsed.value, projectDir: project.root };
|