@1agh/maude 0.48.0 → 0.49.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/apps/studio/api.ts +18 -2
- package/apps/studio/assets-s3.ts +291 -0
- package/apps/studio/collab/origins.ts +150 -0
- package/apps/studio/collab/protocol.ts +36 -9
- package/apps/studio/collab/room.ts +124 -0
- package/apps/studio/context.ts +9 -0
- package/apps/studio/dist/client.bundle.js +1 -1
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/http.ts +45 -0
- package/apps/studio/server.ts +75 -2
- package/apps/studio/sync/autocommit.ts +299 -0
- package/apps/studio/sync/doc-name.ts +228 -0
- package/apps/studio/sync/index.ts +95 -1
- package/apps/studio/sync/workspace-signin.ts +301 -0
- package/apps/studio/test/assets-s3.test.ts +249 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/collab-origin-gate.test.ts +323 -0
- package/apps/studio/test/sync-autocommit.test.ts +334 -0
- package/apps/studio/test/sync-doc-name.test.ts +281 -0
- package/apps/studio/test/workspace-containment.test.ts +258 -0
- package/apps/studio/test/workspace-signin.test.ts +270 -0
- package/apps/studio/use-collab.tsx +28 -1
- package/apps/studio/workspace-mode.ts +210 -0
- package/apps/studio/ws.ts +11 -2
- package/cli/commands/hub-workspace.mjs +341 -0
- package/cli/commands/hub.mjs +325 -3
- package/cli/commands/hub.test.mjs +14 -1
- package/cli/lib/cell-plan.mjs +302 -0
- package/cli/lib/cell-plan.test.mjs +225 -0
- package/cli/lib/gitignore-block.mjs +12 -1
- package/cli/lib/workspace-plan.mjs +422 -0
- package/cli/lib/workspace-plan.test.mjs +223 -0
- package/package.json +8 -8
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// Autosave → append-only git commits — Cloud Phase 3 Task 1.
|
|
2
|
+
//
|
|
3
|
+
// On a laptop, autosave writing a file IS the save: the developer's own git is
|
|
4
|
+
// the history, and they commit when they mean to. In a workspace cell there is
|
|
5
|
+
// no developer at the keyboard, so an unwritten history means the only record
|
|
6
|
+
// of a design is its current bytes — one bad sync away from unrecoverable.
|
|
7
|
+
//
|
|
8
|
+
// So the cell commits. Three rules make that safe rather than merely noisy:
|
|
9
|
+
//
|
|
10
|
+
// 1. APPEND-ONLY. `git add` + `git commit`, ever. No amend, no rebase, no
|
|
11
|
+
// reset, no `checkout --` over a dirty tree, and NEVER a force-push. The
|
|
12
|
+
// history is allowed to be ugly; it is not allowed to lose a state that
|
|
13
|
+
// once existed.
|
|
14
|
+
// 2. AUTHORSHIP IS THE EDITING HUMAN. git separates author from committer
|
|
15
|
+
// precisely for this: the author is the person whose edit this was (from
|
|
16
|
+
// presence), the committer is the workspace bot. `git blame` then answers
|
|
17
|
+
// "who designed this" instead of "the server did", which is the whole
|
|
18
|
+
// reason to keep history at all.
|
|
19
|
+
// 3. QUIESCENCE, NOT KEYSTROKES. Commits fire after edits stop, so a typing
|
|
20
|
+
// session is one commit rather than four hundred.
|
|
21
|
+
//
|
|
22
|
+
// The disk write has ALREADY happened by the time anything here runs. A git
|
|
23
|
+
// failure therefore never loses work — it leaves the change uncommitted and
|
|
24
|
+
// retries on the next quiescence. That ordering is deliberate: making the
|
|
25
|
+
// commit a precondition of the save would turn a transient git error into
|
|
26
|
+
// data loss, which is precisely backwards.
|
|
27
|
+
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
|
|
30
|
+
/** How long the tree must be quiet before a commit fires. */
|
|
31
|
+
const DEFAULT_DEBOUNCE_MS = 3000;
|
|
32
|
+
|
|
33
|
+
export interface GitRunResult {
|
|
34
|
+
code: number;
|
|
35
|
+
stdout: string;
|
|
36
|
+
stderr: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Injected so tests drive a real repo and callers can swap the runner. */
|
|
40
|
+
export type GitRunner = (args: string[], opts: { cwd: string }) => Promise<GitRunResult>;
|
|
41
|
+
|
|
42
|
+
export interface EditAttribution {
|
|
43
|
+
/** Display name from presence, e.g. "Alice Novak". */
|
|
44
|
+
name: string;
|
|
45
|
+
/** Address from presence, or a synthesized stable one. */
|
|
46
|
+
email: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AutoCommitOptions {
|
|
50
|
+
repoRoot: string;
|
|
51
|
+
run: GitRunner;
|
|
52
|
+
/** Quiescence window. */
|
|
53
|
+
debounceMs?: number;
|
|
54
|
+
/** Committer identity — the machine, never the human. */
|
|
55
|
+
bot?: EditAttribution;
|
|
56
|
+
log?: Pick<Console, 'warn' | 'error' | 'log'>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AutoCommit {
|
|
60
|
+
/**
|
|
61
|
+
* Record that `relPath` changed, attributed to `who`. Repeated calls within
|
|
62
|
+
* the debounce window coalesce into one commit.
|
|
63
|
+
*/
|
|
64
|
+
note(relPath: string, who?: EditAttribution | null): void;
|
|
65
|
+
/** Force the pending commit now (branch switch, shutdown). */
|
|
66
|
+
flush(): Promise<CommitOutcome | null>;
|
|
67
|
+
/** Pending paths, for tests + status surfaces. */
|
|
68
|
+
pending(): string[];
|
|
69
|
+
stop(): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type CommitOutcome =
|
|
73
|
+
| { ok: true; sha: string; files: string[]; author: EditAttribution }
|
|
74
|
+
| { ok: false; reason: 'nothing-to-commit' | 'git-failed'; detail?: string; files: string[] };
|
|
75
|
+
|
|
76
|
+
const DEFAULT_BOT: EditAttribution = {
|
|
77
|
+
name: 'Maude Workspace',
|
|
78
|
+
email: 'workspace@maude.local',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Attribution for an edit whose author we don't know.
|
|
83
|
+
*
|
|
84
|
+
* Deliberately NOT the bot: attributing an anonymous human's work to the server
|
|
85
|
+
* makes `git blame` lie in a way that is hard to notice later. "Unknown" is
|
|
86
|
+
* honest, and it is visibly wrong in a way that prompts a fix.
|
|
87
|
+
*/
|
|
88
|
+
export const UNKNOWN_AUTHOR: EditAttribution = {
|
|
89
|
+
name: 'Unknown editor',
|
|
90
|
+
email: 'unknown@maude.local',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Sanitize a presence-supplied identity before it reaches a git argument.
|
|
95
|
+
*
|
|
96
|
+
* Presence comes from peers over the hub, which is semi-trusted (DDR-054): a
|
|
97
|
+
* name is attacker-influenceable text. Newlines are the specific hazard —
|
|
98
|
+
* `git commit --author` takes `Name <email>`, and an embedded newline could
|
|
99
|
+
* forge trailer lines in the commit message. Everything is passed as argv (no
|
|
100
|
+
* shell), so this is about the git format, not shell quoting.
|
|
101
|
+
*/
|
|
102
|
+
export function sanitizeAttribution(who: EditAttribution | null | undefined): EditAttribution {
|
|
103
|
+
if (!who) return UNKNOWN_AUTHOR;
|
|
104
|
+
const clean = (s: string, fallback: string) => {
|
|
105
|
+
const out = String(s ?? '')
|
|
106
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control chars is the point.
|
|
107
|
+
.replace(/[\s\u0000-\u001f\u007f-\u009f]+/g, ' ')
|
|
108
|
+
.replace(/[<>]/g, '')
|
|
109
|
+
.trim()
|
|
110
|
+
.slice(0, 96);
|
|
111
|
+
return out || fallback;
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
name: clean(who.name, UNKNOWN_AUTHOR.name),
|
|
115
|
+
email: clean(who.email, UNKNOWN_AUTHOR.email),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `Name <email>` as git's `--author` wants it. */
|
|
120
|
+
export function formatAuthor(who: EditAttribution): string {
|
|
121
|
+
const s = sanitizeAttribution(who);
|
|
122
|
+
return `${s.name} <${s.email}>`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Commit subject. Names WHAT changed rather than "autosave", because a history
|
|
127
|
+
* of four hundred identical subjects is the same as no history.
|
|
128
|
+
*/
|
|
129
|
+
export function commitMessage(files: string[], who: EditAttribution): string {
|
|
130
|
+
// Strip from the FIRST dot, not the last: `Screen.tsx` and `Screen.meta.json`
|
|
131
|
+
// are one canvas, and counting them as two would make every commit subject
|
|
132
|
+
// overstate what changed.
|
|
133
|
+
const canvases = [...new Set(files.map((f) => path.basename(f).replace(/\..*$/, '')))].sort();
|
|
134
|
+
const subject =
|
|
135
|
+
canvases.length === 1
|
|
136
|
+
? `design: update ${canvases[0]}`
|
|
137
|
+
: `design: update ${canvases.length} canvases`;
|
|
138
|
+
const body = [
|
|
139
|
+
'',
|
|
140
|
+
canvases.length > 1 ? canvases.map((c) => `- ${c}`).join('\n') : '',
|
|
141
|
+
'',
|
|
142
|
+
`Edited by ${who.name} <${who.email}> via the Maude workspace.`,
|
|
143
|
+
'Autosaved — append-only; this history is never rewritten.',
|
|
144
|
+
]
|
|
145
|
+
.filter((line, i, all) => !(line === '' && all[i - 1] === ''))
|
|
146
|
+
.join('\n');
|
|
147
|
+
return `${subject}\n${body}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
|
|
151
|
+
const {
|
|
152
|
+
repoRoot,
|
|
153
|
+
run,
|
|
154
|
+
debounceMs = DEFAULT_DEBOUNCE_MS,
|
|
155
|
+
bot = DEFAULT_BOT,
|
|
156
|
+
log = console,
|
|
157
|
+
} = opts;
|
|
158
|
+
|
|
159
|
+
const touched = new Set<string>();
|
|
160
|
+
let author: EditAttribution | null = null;
|
|
161
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
162
|
+
let inFlight: Promise<CommitOutcome | null> | null = null;
|
|
163
|
+
let stopped = false;
|
|
164
|
+
|
|
165
|
+
function note(relPath: string, who?: EditAttribution | null): void {
|
|
166
|
+
if (stopped) return;
|
|
167
|
+
touched.add(relPath);
|
|
168
|
+
// Last writer wins for attribution. A commit that coalesced two people's
|
|
169
|
+
// edits can only name one author; the message body names them, and the
|
|
170
|
+
// alternative (splitting per author) would fight the quiescence batching
|
|
171
|
+
// that keeps this history readable.
|
|
172
|
+
if (who) author = who;
|
|
173
|
+
if (timer) clearTimeout(timer);
|
|
174
|
+
timer = setTimeout(() => {
|
|
175
|
+
timer = null;
|
|
176
|
+
void flush();
|
|
177
|
+
}, debounceMs);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function flush(): Promise<CommitOutcome | null> {
|
|
181
|
+
if (inFlight) return inFlight;
|
|
182
|
+
if (timer) {
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
timer = null;
|
|
185
|
+
}
|
|
186
|
+
if (touched.size === 0) return null;
|
|
187
|
+
|
|
188
|
+
const files = [...touched].sort();
|
|
189
|
+
const who = sanitizeAttribution(author);
|
|
190
|
+
touched.clear();
|
|
191
|
+
author = null;
|
|
192
|
+
|
|
193
|
+
inFlight = (async (): Promise<CommitOutcome> => {
|
|
194
|
+
try {
|
|
195
|
+
// Stage ONLY what changed. `git add -A` in a workspace would sweep in
|
|
196
|
+
// whatever else is in the tree — including files a future feature drops
|
|
197
|
+
// there — and the cell must never commit something it wasn't told about.
|
|
198
|
+
const add = await run(['add', '--', ...files], { cwd: repoRoot });
|
|
199
|
+
if (add.code !== 0) {
|
|
200
|
+
log.warn?.(`[autocommit] git add failed: ${add.stderr.trim()}`);
|
|
201
|
+
return { ok: false, reason: 'git-failed', detail: add.stderr.trim(), files };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Nothing staged ⇒ the write was a no-op (an echo, or identical bytes).
|
|
205
|
+
// Not an error, and committing an empty change would be noise.
|
|
206
|
+
const staged = await run(['diff', '--cached', '--name-only'], { cwd: repoRoot });
|
|
207
|
+
if (staged.code === 0 && staged.stdout.trim() === '') {
|
|
208
|
+
return { ok: false, reason: 'nothing-to-commit', files };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const commit = await run(
|
|
212
|
+
[
|
|
213
|
+
'-c',
|
|
214
|
+
`user.name=${bot.name}`,
|
|
215
|
+
'-c',
|
|
216
|
+
`user.email=${bot.email}`,
|
|
217
|
+
'commit',
|
|
218
|
+
'--author',
|
|
219
|
+
formatAuthor(who),
|
|
220
|
+
'--only',
|
|
221
|
+
'--message',
|
|
222
|
+
commitMessage(files, who),
|
|
223
|
+
'--',
|
|
224
|
+
...files,
|
|
225
|
+
],
|
|
226
|
+
{ cwd: repoRoot }
|
|
227
|
+
);
|
|
228
|
+
if (commit.code !== 0) {
|
|
229
|
+
log.warn?.(`[autocommit] git commit failed: ${commit.stderr.trim()}`);
|
|
230
|
+
// The bytes are on disk. Re-queue so the next quiescence retries
|
|
231
|
+
// rather than silently dropping the change from history.
|
|
232
|
+
for (const f of files) touched.add(f);
|
|
233
|
+
return { ok: false, reason: 'git-failed', detail: commit.stderr.trim(), files };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const head = await run(['rev-parse', 'HEAD'], { cwd: repoRoot });
|
|
237
|
+
const sha = head.stdout.trim();
|
|
238
|
+
log.log?.(`[autocommit] ${sha.slice(0, 8)} ${files.length} file(s) by ${who.name}`);
|
|
239
|
+
return { ok: true, sha, files, author: who };
|
|
240
|
+
} finally {
|
|
241
|
+
inFlight = null;
|
|
242
|
+
}
|
|
243
|
+
})();
|
|
244
|
+
|
|
245
|
+
return inFlight;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
note,
|
|
250
|
+
flush,
|
|
251
|
+
pending: () => [...touched].sort(),
|
|
252
|
+
stop() {
|
|
253
|
+
stopped = true;
|
|
254
|
+
if (timer) {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
timer = null;
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Push to a mirror remote, refusing anything that would rewrite it.
|
|
264
|
+
*
|
|
265
|
+
* A cell that force-pushes destroys work that exists only on the remote —
|
|
266
|
+
* exactly the hazard DDR-119 was written about, arriving from the other
|
|
267
|
+
* direction. On rejection the correct behaviour is to STOP and surface it, not
|
|
268
|
+
* to "resolve" it: a non-fast-forward means someone else's commits are there,
|
|
269
|
+
* and the cell has no way to know whether merging them is right.
|
|
270
|
+
*/
|
|
271
|
+
export async function pushMirror({
|
|
272
|
+
repoRoot,
|
|
273
|
+
run,
|
|
274
|
+
remote = 'origin',
|
|
275
|
+
branch,
|
|
276
|
+
log = console,
|
|
277
|
+
}: {
|
|
278
|
+
repoRoot: string;
|
|
279
|
+
run: GitRunner;
|
|
280
|
+
remote?: string;
|
|
281
|
+
branch: string;
|
|
282
|
+
log?: Pick<Console, 'warn'>;
|
|
283
|
+
}): Promise<{ ok: boolean; rejected: boolean; detail?: string }> {
|
|
284
|
+
// No --force, no --force-with-lease, no +refspec. If this ever needs one,
|
|
285
|
+
// that is a design conversation, not a flag.
|
|
286
|
+
const res = await run(['push', remote, `refs/heads/${branch}:refs/heads/${branch}`], {
|
|
287
|
+
cwd: repoRoot,
|
|
288
|
+
});
|
|
289
|
+
if (res.code === 0) return { ok: true, rejected: false };
|
|
290
|
+
const detail = `${res.stderr}\n${res.stdout}`.trim();
|
|
291
|
+
const rejected = /\brejected\b|non-fast-forward|fetch first/i.test(detail);
|
|
292
|
+
if (rejected) {
|
|
293
|
+
log.warn?.(
|
|
294
|
+
'[autocommit] mirror push REJECTED — someone else saved first. Stopping rather than ' +
|
|
295
|
+
'rewriting their work; the local history is intact and nothing was lost.'
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return { ok: false, rejected, detail };
|
|
299
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Document namespace, client side — `ws/<workspace-id>/<branch>/<slug>` (DDR-192 §5).
|
|
2
|
+
//
|
|
3
|
+
// ⚠ THE GRAMMAR IS MIRRORED in `apps/hub/src/doc-namespace.mjs` and the two are
|
|
4
|
+
// pinned to each other by `test/sync-doc-name.test.ts`, which imports the hub's
|
|
5
|
+
// implementation and asserts both agree over the same corpus. They are separate
|
|
6
|
+
// files on purpose: the hub image installs frozen against its own bun.lock and
|
|
7
|
+
// must not reach into apps/studio. Change one, change the other.
|
|
8
|
+
//
|
|
9
|
+
// WHAT IS NAMESPACED AND WHAT IS NOT: only the WIRE name changes. Every local
|
|
10
|
+
// map — the provider registry, the sync agent, the projection, `_history/`,
|
|
11
|
+
// `_comments/` — stays keyed by the flat slug. The namespace exists to keep two
|
|
12
|
+
// projects (or two branches) from colliding INSIDE A HUB; on disk they already
|
|
13
|
+
// live in different directories.
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
/** The prefix that marks a namespaced documentName. */
|
|
19
|
+
export const DOC_NAMESPACE_PREFIX = 'ws';
|
|
20
|
+
|
|
21
|
+
/** Max length of one path component (workspace id / branch / slug). */
|
|
22
|
+
export const COMPONENT_MAX = 64;
|
|
23
|
+
|
|
24
|
+
export interface DocNamespace {
|
|
25
|
+
workspaceId: string;
|
|
26
|
+
branch: string;
|
|
27
|
+
slug: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Normalize one path component into the namespace charset. `/` is the
|
|
32
|
+
* separator, so a git branch like `feature/foo` becomes `feature-foo`.
|
|
33
|
+
*/
|
|
34
|
+
export function sanitizeComponent(raw: string): string {
|
|
35
|
+
if (typeof raw !== 'string') return '';
|
|
36
|
+
return raw
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
39
|
+
.replace(/-{2,}/g, '-')
|
|
40
|
+
.replace(/^[-.]+|[-.]+$/g, '')
|
|
41
|
+
.slice(0, COMPONENT_MAX)
|
|
42
|
+
.replace(/[-.]+$/g, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Build `ws/<workspace-id>/<branch>/<slug>`. Throws if a component is empty. */
|
|
46
|
+
export function buildDocName({ workspaceId, branch, slug }: DocNamespace): string {
|
|
47
|
+
const w = sanitizeComponent(workspaceId);
|
|
48
|
+
const b = sanitizeComponent(branch);
|
|
49
|
+
const s = sanitizeComponent(slug);
|
|
50
|
+
if (!w) throw new Error('buildDocName: workspaceId is empty after normalization');
|
|
51
|
+
if (!b) throw new Error('buildDocName: branch is empty after normalization');
|
|
52
|
+
if (!s) throw new Error('buildDocName: slug is empty after normalization');
|
|
53
|
+
return `${DOC_NAMESPACE_PREFIX}/${w}/${b}/${s}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Parse a documentName; null means a legacy flat slug (expected, not an error). */
|
|
57
|
+
export function parseDocName(name: string): DocNamespace | null {
|
|
58
|
+
if (typeof name !== 'string' || name.length === 0) return null;
|
|
59
|
+
const parts = name.split('/');
|
|
60
|
+
if (parts.length !== 4) return null;
|
|
61
|
+
const [prefix, workspaceId, branch, slug] = parts;
|
|
62
|
+
if (prefix !== DOC_NAMESPACE_PREFIX) return null;
|
|
63
|
+
if (!workspaceId || !branch || !slug) return null;
|
|
64
|
+
return { workspaceId, branch, slug };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True when `name` is a namespaced documentName. */
|
|
68
|
+
export function isNamespaced(name: string): boolean {
|
|
69
|
+
return parseDocName(name) !== null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Resolution — where the workspace id and the branch actually come from
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Current branch, read straight from `.git/HEAD` (no subprocess — the same
|
|
78
|
+
* source `collab/git-lifecycle.ts` watches).
|
|
79
|
+
*
|
|
80
|
+
* A detached HEAD yields `detached-<sha7>`, which is a *stable* name for that
|
|
81
|
+
* commit: two peers detached at the same commit meet, and a peer on a branch
|
|
82
|
+
* never accidentally shares a doc with a detached checkout.
|
|
83
|
+
*
|
|
84
|
+
* Returns null when there is no git repo at all.
|
|
85
|
+
*/
|
|
86
|
+
export function readBranch(repoRoot: string): string | null {
|
|
87
|
+
const headPath = path.join(repoRoot, '.git', 'HEAD');
|
|
88
|
+
if (!existsSync(headPath)) return null;
|
|
89
|
+
let head: string;
|
|
90
|
+
try {
|
|
91
|
+
head = readFileSync(headPath, 'utf8').trim();
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
|
|
96
|
+
if (ref?.[1]) return ref[1];
|
|
97
|
+
if (/^[0-9a-f]{7,40}$/i.test(head)) return `detached-${head.slice(0, 7)}`;
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Read the `origin` remote URL from `.git/config` without shelling out.
|
|
103
|
+
* Returns null when there is no origin (a purely local repo).
|
|
104
|
+
*/
|
|
105
|
+
export function readOriginUrl(repoRoot: string): string | null {
|
|
106
|
+
const cfgPath = path.join(repoRoot, '.git', 'config');
|
|
107
|
+
if (!existsSync(cfgPath)) return null;
|
|
108
|
+
let raw: string;
|
|
109
|
+
try {
|
|
110
|
+
raw = readFileSync(cfgPath, 'utf8');
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const section = raw.match(/\[remote "origin"\]([\s\S]*?)(?=\n\[|$)/);
|
|
115
|
+
const url = section?.[1]?.match(/^\s*url\s*=\s*(.+)$/m)?.[1]?.trim();
|
|
116
|
+
return url && url.length > 0 ? url : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Reduce a git remote URL to a stable `<owner>-<repo>` identity, so
|
|
121
|
+
* `git@github.com:1aGh/maude.git` and `https://github.com/1aGh/maude` — the
|
|
122
|
+
* same project cloned two different ways — produce the SAME workspace id.
|
|
123
|
+
*
|
|
124
|
+
* Getting this wrong is not a cosmetic bug: two peers of one project that
|
|
125
|
+
* derive different ids would stop meeting, and each would see the other's docs
|
|
126
|
+
* as absent.
|
|
127
|
+
*/
|
|
128
|
+
export function workspaceIdFromRemote(url: string): string {
|
|
129
|
+
let s = url.trim().replace(/\.git$/i, '');
|
|
130
|
+
s = s.replace(/^[a-z0-9+.-]+:\/\//i, ''); // scheme
|
|
131
|
+
s = s.replace(/^[^@/]+@/, ''); // user@
|
|
132
|
+
s = s.replace(/^([^/:]+):/, '$1/'); // scp-style host:path → host/path
|
|
133
|
+
const segments = s.split('/').filter(Boolean);
|
|
134
|
+
const tail = segments.slice(-2); // <owner>/<repo>
|
|
135
|
+
return sanitizeComponent(tail.join('-'));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ResolveWorkspaceOpts {
|
|
139
|
+
/** Explicit id from config — authoritative when present (the cloud sets it). */
|
|
140
|
+
explicit?: string | undefined;
|
|
141
|
+
repoRoot: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resolve the workspace id, or null when it cannot be derived in a way that is
|
|
146
|
+
* STABLE ACROSS MACHINES.
|
|
147
|
+
*
|
|
148
|
+
* Order: explicit config → git origin remote. There is deliberately no
|
|
149
|
+
* directory-name fallback: a local path is not the same on two machines, so
|
|
150
|
+
* deriving from it would split peers of the same project into separate
|
|
151
|
+
* documents — the exact failure the namespace exists to prevent, arrived at
|
|
152
|
+
* from the other direction. No stable id ⇒ stay flat (see `createDocNameResolver`).
|
|
153
|
+
*/
|
|
154
|
+
export function resolveWorkspaceId({ explicit, repoRoot }: ResolveWorkspaceOpts): string | null {
|
|
155
|
+
const fromConfig = sanitizeComponent(explicit ?? '');
|
|
156
|
+
if (fromConfig) return fromConfig;
|
|
157
|
+
const origin = readOriginUrl(repoRoot);
|
|
158
|
+
if (!origin) return null;
|
|
159
|
+
const derived = workspaceIdFromRemote(origin);
|
|
160
|
+
return derived || null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface DocNameResolver {
|
|
164
|
+
/** Map a local canvas slug to the name used on the wire. */
|
|
165
|
+
(slug: string): string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface DocNameResolverOpts {
|
|
169
|
+
repoRoot: string;
|
|
170
|
+
/** `linkedHub.workspaceId` when the config carries one. */
|
|
171
|
+
explicitWorkspaceId?: string | undefined;
|
|
172
|
+
/** `MAUDE_HUB_NAMESPACED` — '1' forces on, '0' forces off, absent = auto. */
|
|
173
|
+
flag?: string | undefined;
|
|
174
|
+
/** Test seam: override branch detection. */
|
|
175
|
+
branch?: string | undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Build the slug → documentName mapping for this process.
|
|
180
|
+
*
|
|
181
|
+
* Rollout rule (DDR-192 §5): namespacing CHANGES DOC IDENTITY, so it is opt-in
|
|
182
|
+
* for now and becomes default-on in workspace mode (Phase 3).
|
|
183
|
+
*
|
|
184
|
+
* MAUDE_HUB_NAMESPACED=0 → always flat, even with an explicit workspace id
|
|
185
|
+
* MAUDE_HUB_NAMESPACED=1 → namespaced, and it is an ERROR to be unable to
|
|
186
|
+
* (an operator who asked for isolation gets a loud
|
|
187
|
+
* failure, never a silent fallback into a shared doc)
|
|
188
|
+
* unset → namespaced only when config declares a workspace
|
|
189
|
+
* id explicitly; otherwise flat
|
|
190
|
+
*
|
|
191
|
+
* The auto case is deliberately NOT "namespace whenever a git origin exists":
|
|
192
|
+
* flipping identity under an existing linked hub would make every doc look
|
|
193
|
+
* freshly empty. DDR-076 keeps that from eating local files, but the hub-side
|
|
194
|
+
* history would be orphaned, and nobody asked for that on upgrade.
|
|
195
|
+
*/
|
|
196
|
+
export function createDocNameResolver(opts: DocNameResolverOpts): DocNameResolver {
|
|
197
|
+
const flag = opts.flag;
|
|
198
|
+
if (flag === '0') return (slug) => slug;
|
|
199
|
+
|
|
200
|
+
const workspaceId = resolveWorkspaceId({
|
|
201
|
+
explicit: opts.explicitWorkspaceId,
|
|
202
|
+
repoRoot: opts.repoRoot,
|
|
203
|
+
});
|
|
204
|
+
const branch = sanitizeComponent(opts.branch ?? readBranch(opts.repoRoot) ?? '');
|
|
205
|
+
|
|
206
|
+
if (flag === '1') {
|
|
207
|
+
if (!workspaceId) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
'MAUDE_HUB_NAMESPACED=1 but no workspace id could be resolved. Set ' +
|
|
210
|
+
'`linkedHub.workspaceId` in .design/config.json, or give the repo an ' +
|
|
211
|
+
'`origin` remote. Refusing to fall back to flat slugs — that would put ' +
|
|
212
|
+
'this project in a shared document namespace (DDR-192 §5).'
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (!branch) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
'MAUDE_HUB_NAMESPACED=1 but the current branch could not be read from ' +
|
|
218
|
+
'.git/HEAD. Refusing to fall back to flat slugs (DDR-192 §5).'
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const explicitlyDeclared = sanitizeComponent(opts.explicitWorkspaceId ?? '') !== '';
|
|
224
|
+
const on = flag === '1' || (explicitlyDeclared && !!workspaceId && !!branch);
|
|
225
|
+
if (!on || !workspaceId || !branch) return (slug) => slug;
|
|
226
|
+
|
|
227
|
+
return (slug) => buildDocName({ workspaceId, branch, slug });
|
|
228
|
+
}
|
|
@@ -28,11 +28,14 @@ import { Y_TYPES } from '../collab/persistence.ts';
|
|
|
28
28
|
import type { Context } from '../context.ts';
|
|
29
29
|
import { createHistory } from '../history.ts';
|
|
30
30
|
import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
|
|
31
|
+
import { atomicWrite } from './atomic-write.ts';
|
|
32
|
+
import { createAutoCommit } from './autocommit.ts';
|
|
31
33
|
import {
|
|
32
34
|
type ConnectionMonitor,
|
|
33
35
|
createConnectionMonitor,
|
|
34
36
|
type ProviderStatus,
|
|
35
37
|
} from './connection-state.ts';
|
|
38
|
+
import { createDocNameResolver } from './doc-name.ts';
|
|
36
39
|
import { createEchoGuard } from './echo-guard.ts';
|
|
37
40
|
import { createFsReader, type FsReader } from './fs-mirror.ts';
|
|
38
41
|
import { getHubToken } from './hubs-config.ts';
|
|
@@ -241,6 +244,42 @@ export function createSyncRuntime(
|
|
|
241
244
|
return null;
|
|
242
245
|
}
|
|
243
246
|
|
|
247
|
+
// DDR-192 §5 — slug → wire documentName. Only the WIRE name is namespaced;
|
|
248
|
+
// every local map (providers, agents, projections, _history/) stays keyed by
|
|
249
|
+
// the flat slug. Opt-in for now (see createDocNameResolver's rollout rule);
|
|
250
|
+
// a bad MAUDE_HUB_NAMESPACED=1 with no resolvable workspace id throws here
|
|
251
|
+
// rather than silently falling back into a shared namespace.
|
|
252
|
+
let docNameFor: (slug: string) => string;
|
|
253
|
+
try {
|
|
254
|
+
docNameFor = createDocNameResolver({
|
|
255
|
+
repoRoot: ctx.paths.repoRoot,
|
|
256
|
+
explicitWorkspaceId: linkedHub.workspaceId,
|
|
257
|
+
flag: process.env.MAUDE_HUB_NAMESPACED,
|
|
258
|
+
});
|
|
259
|
+
} catch (err) {
|
|
260
|
+
console.error(`[sync] refusing to start: ${(err as Error).message}`);
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Cloud Phase 3 Task 1 — in a workspace cell, a disk write is only half the
|
|
265
|
+
// save: nobody is at a keyboard to commit, so the cell does it. Off entirely
|
|
266
|
+
// outside workspace mode, where the developer's own git IS the history and
|
|
267
|
+
// committing under them would be an intrusion (DDR-119).
|
|
268
|
+
const autoCommit =
|
|
269
|
+
process.env.MAUDE_WORKSPACE_MODE === '1'
|
|
270
|
+
? createAutoCommit({
|
|
271
|
+
repoRoot: ctx.paths.repoRoot,
|
|
272
|
+
run: async (args, { cwd }) => {
|
|
273
|
+
const proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' });
|
|
274
|
+
const [stdout, stderr] = await Promise.all([
|
|
275
|
+
new Response(proc.stdout).text(),
|
|
276
|
+
new Response(proc.stderr).text(),
|
|
277
|
+
]);
|
|
278
|
+
return { code: await proc.exited, stdout, stderr };
|
|
279
|
+
},
|
|
280
|
+
})
|
|
281
|
+
: null;
|
|
282
|
+
|
|
244
283
|
const resolvedToken = getHubToken(linkedHub.url);
|
|
245
284
|
if (!resolvedToken) {
|
|
246
285
|
console.warn(
|
|
@@ -556,6 +595,35 @@ export function createSyncRuntime(
|
|
|
556
595
|
* permanent-rejection re-probe (which passes the EXISTING doc so the
|
|
557
596
|
* agent/projection wiring — doc-scoped — survives the provider swap).
|
|
558
597
|
*/
|
|
598
|
+
/**
|
|
599
|
+
* Who is editing this canvas right now, from hub awareness.
|
|
600
|
+
*
|
|
601
|
+
* Presence carries a display name and no address, so the address is
|
|
602
|
+
* synthesized and clearly marked as derived — inventing a plausible-looking
|
|
603
|
+
* real address would put an unverified identity into permanent git history.
|
|
604
|
+
* Absent a remote peer the answer is null, which `autocommit` turns into
|
|
605
|
+
* "Unknown editor" rather than attributing the work to the server.
|
|
606
|
+
*/
|
|
607
|
+
const editorOf = (slug: string): { name: string; email: string } | null => {
|
|
608
|
+
const awareness = providers.get(slug)?.awareness;
|
|
609
|
+
if (!awareness) return null;
|
|
610
|
+
for (const [clientId, state] of awareness.getStates() as Map<
|
|
611
|
+
number,
|
|
612
|
+
{ name?: string } | undefined
|
|
613
|
+
>) {
|
|
614
|
+
if (clientId === awareness.clientID) continue; // that's us, the cell
|
|
615
|
+
const name = state?.name?.trim();
|
|
616
|
+
if (name) {
|
|
617
|
+
const slugified = name
|
|
618
|
+
.toLowerCase()
|
|
619
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
620
|
+
.replace(/^-|-$/g, '');
|
|
621
|
+
return { name, email: `${slugified || 'peer'}@peers.maude.local` };
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return null;
|
|
625
|
+
};
|
|
626
|
+
|
|
559
627
|
const connectCanvas = async (
|
|
560
628
|
canvas: CanvasDescriptor,
|
|
561
629
|
canvasPaths: import('./agent.ts').CanvasSyncPaths,
|
|
@@ -565,7 +633,7 @@ export function createSyncRuntime(
|
|
|
565
633
|
const provider = await providerFactory({
|
|
566
634
|
url: linkedHub.url,
|
|
567
635
|
token,
|
|
568
|
-
documentName: canvas.slug,
|
|
636
|
+
documentName: docNameFor(canvas.slug),
|
|
569
637
|
document,
|
|
570
638
|
});
|
|
571
639
|
providers.set(canvas.slug, provider);
|
|
@@ -653,6 +721,20 @@ export function createSyncRuntime(
|
|
|
653
721
|
echoGuard,
|
|
654
722
|
adopt: adoptOnce,
|
|
655
723
|
journal: journal ?? undefined,
|
|
724
|
+
// Wrap the writer rather than adding a new hook: every path the
|
|
725
|
+
// agent materializes to disk goes through it, so a future write
|
|
726
|
+
// surface is committed automatically instead of being forgotten.
|
|
727
|
+
...(autoCommit
|
|
728
|
+
? {
|
|
729
|
+
writer: (file: string, bytes: string | Uint8Array) => {
|
|
730
|
+
atomicWrite(file, bytes);
|
|
731
|
+
autoCommit.note(
|
|
732
|
+
path.relative(ctx.paths.repoRoot, file),
|
|
733
|
+
editorOf(canvas.slug)
|
|
734
|
+
);
|
|
735
|
+
},
|
|
736
|
+
}
|
|
737
|
+
: {}),
|
|
656
738
|
snapshot: async (content, reason) => {
|
|
657
739
|
try {
|
|
658
740
|
const snap = await history.writeSnapshot(relBody, content, reason);
|
|
@@ -770,6 +852,18 @@ export function createSyncRuntime(
|
|
|
770
852
|
async function stop(): Promise<void> {
|
|
771
853
|
if (stopped) return;
|
|
772
854
|
stopped = true;
|
|
855
|
+
// Commit whatever is still inside the quiescence window BEFORE tearing
|
|
856
|
+
// anything down. Shutting down mid-window would leave the last edits on
|
|
857
|
+
// disk but out of history — the one state this whole mechanism exists to
|
|
858
|
+
// make impossible.
|
|
859
|
+
if (autoCommit) {
|
|
860
|
+
try {
|
|
861
|
+
await autoCommit.flush();
|
|
862
|
+
} catch (err) {
|
|
863
|
+
console.error('[sync] final autocommit failed:', err);
|
|
864
|
+
}
|
|
865
|
+
autoCommit.stop();
|
|
866
|
+
}
|
|
773
867
|
for (const detach of awarenessDetaches) {
|
|
774
868
|
try {
|
|
775
869
|
detach();
|