@1agh/maude 0.47.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/README.md +7 -6
- package/apps/studio/acp/bridge.ts +8 -1
- package/apps/studio/acp/plugin-bootstrap.ts +15 -1
- package/apps/studio/api.ts +18 -2
- package/apps/studio/assets-s3.ts +291 -0
- package/apps/studio/build.ts +1 -1
- package/apps/studio/client/export-center.jsx +7 -6
- package/apps/studio/client/github.js +11 -4
- 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 +282 -282
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
- package/apps/studio/examples/perf-100-artboards.tsx +1 -1
- package/apps/studio/exporters/pdf.ts +35 -1
- package/apps/studio/git/service.ts +4 -1
- package/apps/studio/http.ts +45 -0
- package/apps/studio/paths.ts +37 -1
- 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/acp-plugin-bootstrap.test.ts +34 -0
- package/apps/studio/test/acp-session-plugins.test.ts +6 -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/exporters/pdf.test.ts +33 -1
- 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/bin/maude.mjs +1 -0
- 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/commands/init.mjs +80 -3
- package/cli/commands/kg.mjs +368 -0
- package/cli/commands/kg.test.mjs +118 -0
- package/cli/lib/cell-plan.mjs +302 -0
- package/cli/lib/cell-plan.test.mjs +225 -0
- package/cli/lib/ddr-to-kgai.mjs +648 -0
- package/cli/lib/ddr-to-kgai.test.mjs +99 -0
- package/cli/lib/flow-design-integration.test.mjs +2 -2
- package/cli/lib/gitignore-block.mjs +16 -1
- package/cli/lib/plugin-name-namespace.test.mjs +71 -0
- package/cli/lib/workspace-plan.mjs +422 -0
- package/cli/lib/workspace-plan.test.mjs +223 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +17 -0
- package/plugins/flow/.claude-plugin/config.schema.json +66 -0
- package/plugins/flow/dependencies.json +17 -0
package/apps/studio/server.ts
CHANGED
|
@@ -34,6 +34,12 @@ import { createHttp } from './http.ts';
|
|
|
34
34
|
import { createInspect } from './inspect.ts';
|
|
35
35
|
import { startHeapWatch } from './mem.ts';
|
|
36
36
|
import { createSyncRuntime } from './sync/index.ts';
|
|
37
|
+
import {
|
|
38
|
+
assertContainment,
|
|
39
|
+
isForbiddenRoute,
|
|
40
|
+
isWorkspaceMode,
|
|
41
|
+
pruneForWorkspace,
|
|
42
|
+
} from './workspace-mode.ts';
|
|
37
43
|
import { createWs, isLoopbackHost, isSameOriginWs, parseCollabSlug, type WsData } from './ws.ts';
|
|
38
44
|
|
|
39
45
|
// Phase 19 / DDR-044 — covers the marketplace-cache-install gap where
|
|
@@ -141,16 +147,73 @@ type BunServer = ReturnType<typeof Bun.serve<WsData, never>>;
|
|
|
141
147
|
// transient-buffer tradeoff on the buffering routes, noted in DDR-148).
|
|
142
148
|
const MAX_REQUEST_BODY = ASSET_MAX_VIDEO_BYTES + 8 * 1024 * 1024;
|
|
143
149
|
|
|
150
|
+
// Containment invariant (DDR-193 §2) — the boot-assert. No-op outside workspace
|
|
151
|
+
// mode; in a cell it refuses to start when a rendering / evaluating / exporting
|
|
152
|
+
// surface is reachable, naming exactly which one. See workspace-mode.ts for why
|
|
153
|
+
// this is a process that will not start rather than a convention.
|
|
154
|
+
//
|
|
155
|
+
// Checked against the ACTUAL route table (`http.routes` keys plus the paths the
|
|
156
|
+
// `fetch` fall-through owns), not a hand-maintained list, so a future route can
|
|
157
|
+
// only escape it by being invisible to both.
|
|
158
|
+
const WORKSPACE_FETCH_ROUTES = ['/_ws/acp', '/_canvas-shell.html', '/_canvas-runtime/'];
|
|
159
|
+
const WORKSPACE = isWorkspaceMode();
|
|
160
|
+
// PRUNE first, then assert over what survived — so the assert is a
|
|
161
|
+
// post-condition on the pruning rather than a second, driftable opinion. A
|
|
162
|
+
// prefix added to the vocabulary then both prunes and is verified, together.
|
|
163
|
+
const pruned = WORKSPACE ? pruneForWorkspace(http.routes) : { routes: http.routes, removed: [] };
|
|
164
|
+
const SERVER_ROUTES = pruned.routes as typeof http.routes;
|
|
165
|
+
if (WORKSPACE && pruned.removed.length > 0) {
|
|
166
|
+
console.log(
|
|
167
|
+
`[studio] workspace mode — withheld ${pruned.removed.length} route(s) that would evaluate ` +
|
|
168
|
+
`tenant content: ${pruned.removed.join(', ')}`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
assertContainment([...Object.keys(SERVER_ROUTES), ...(WORKSPACE ? [] : WORKSPACE_FETCH_ROUTES)], {
|
|
173
|
+
// Presence of the dependency is the signal: a cell image that ships
|
|
174
|
+
// Playwright is one import() away from rendering tenant content. Skippable
|
|
175
|
+
// in a dev checkout, where Playwright is a legitimate devDependency of the
|
|
176
|
+
// E2E harness and would otherwise make workspace mode untestable locally.
|
|
177
|
+
// A BUILT cell image has no such escape — scripts/check-containment.sh
|
|
178
|
+
// enforces the runtime-dependency half at build time.
|
|
179
|
+
resolveModule:
|
|
180
|
+
process.env.MAUDE_WORKSPACE_ALLOW_DEV_MODULES === '1'
|
|
181
|
+
? undefined
|
|
182
|
+
: (specifier) => {
|
|
183
|
+
try {
|
|
184
|
+
return !!import.meta.resolveSync?.(specifier);
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
} catch (err) {
|
|
191
|
+
console.error(`\n${(err as Error).message}\n`);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
if (isWorkspaceMode()) {
|
|
195
|
+
console.log('[studio] workspace mode — sync + git + assets only (DDR-193 containment invariant)');
|
|
196
|
+
}
|
|
197
|
+
|
|
144
198
|
function startServer(port: number): BunServer {
|
|
145
199
|
return Bun.serve<WsData, never>({
|
|
146
200
|
port,
|
|
147
201
|
hostname: '127.0.0.1',
|
|
148
202
|
development: process.env.NODE_ENV !== 'production',
|
|
149
203
|
maxRequestBodySize: MAX_REQUEST_BODY,
|
|
150
|
-
routes:
|
|
204
|
+
routes: SERVER_ROUTES,
|
|
151
205
|
async fetch(req, srv) {
|
|
152
206
|
const pathname = new URL(req.url).pathname;
|
|
153
207
|
|
|
208
|
+
// Containment (DDR-193 §2) — the `fetch` fall-through owns paths that are
|
|
209
|
+
// not in the route table (`/_ws/acp`, `/_canvas-shell.html`,
|
|
210
|
+
// `/_canvas-runtime/*`), so pruning the table alone would leave them
|
|
211
|
+
// reachable. 404, not 403: a cell should look like it never had the
|
|
212
|
+
// feature, rather than like it is refusing one.
|
|
213
|
+
if (WORKSPACE && isForbiddenRoute(pathname)) {
|
|
214
|
+
return new Response('not found', { status: 404 });
|
|
215
|
+
}
|
|
216
|
+
|
|
154
217
|
// Phase 8 — collab WS, binary y-websocket protocol. Loopback-only;
|
|
155
218
|
// DDR-047 makes cross-machine collab a Phase 9 hub-deploy story, not
|
|
156
219
|
// a `--bind 0.0.0.0` flag on this server.
|
|
@@ -167,6 +230,8 @@ function startServer(port: number): BunServer {
|
|
|
167
230
|
remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
|
|
168
231
|
kind: 'collab',
|
|
169
232
|
slug: collabSlug,
|
|
233
|
+
// Privileged shell origin — ungated (see collab/origins.ts).
|
|
234
|
+
realm: 'main',
|
|
170
235
|
},
|
|
171
236
|
});
|
|
172
237
|
if (ok) return undefined as unknown as Response;
|
|
@@ -275,6 +340,10 @@ function startCanvasServer(port: number): BunServer {
|
|
|
275
340
|
remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
|
|
276
341
|
kind: 'collab',
|
|
277
342
|
slug: collabSlug,
|
|
343
|
+
// UNTRUSTED canvas iframe origin (DDR-063 split). Every sync frame
|
|
344
|
+
// from here goes through the origin gate and may never write a
|
|
345
|
+
// body lane — DDR-122 follow-up, collab/origins.ts.
|
|
346
|
+
realm: 'canvas',
|
|
278
347
|
},
|
|
279
348
|
});
|
|
280
349
|
if (ok) return undefined as unknown as Response;
|
|
@@ -377,7 +446,11 @@ ctx.mainOrigin = `http://localhost:${server.port} http://127.0.0.1:${server.port
|
|
|
377
446
|
const CANVAS_ORIGIN_SPLIT = !/^(0|false|off|no)$/i.test(
|
|
378
447
|
process.env.MAUDE_CANVAS_ORIGIN_SPLIT ?? ''
|
|
379
448
|
);
|
|
380
|
-
|
|
449
|
+
// The canvas origin exists to SERVE AND RENDER canvases (DDR-063). A workspace
|
|
450
|
+
// cell has no business starting it — that second origin is the surface the
|
|
451
|
+
// containment invariant is about. Not started here rather than started-and-
|
|
452
|
+
// pruned: there would be nothing left to serve.
|
|
453
|
+
const canvasServer = CANVAS_ORIGIN_SPLIT && !WORKSPACE ? startCanvasServer(0) : null;
|
|
381
454
|
const canvasOrigin = canvasServer ? `http://localhost:${canvasServer.port}` : undefined;
|
|
382
455
|
if (canvasOrigin) ctx.canvasOrigin = canvasOrigin;
|
|
383
456
|
|
|
@@ -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
|
+
}
|