@1agh/maude 0.54.0 → 0.56.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/annotations-layer.tsx +11 -1
- package/apps/studio/bin/_smart-frames.mjs +187 -20
- package/apps/studio/bin/_smart-frames.test.mjs +59 -4
- package/apps/studio/bin/_transcribe.mjs +40 -3
- package/apps/studio/bin/smoke.sh +7 -1
- package/apps/studio/canvas-build-sandbox.ts +101 -9
- package/apps/studio/canvas-build-worker.ts +7 -1
- package/apps/studio/canvas-build.ts +9 -1
- package/apps/studio/client/app.jsx +49 -3
- package/apps/studio/client/github.js +7 -0
- package/apps/studio/client/panels/CloudBar.jsx +505 -45
- package/apps/studio/client/panels/GitPanel.jsx +38 -25
- package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
- package/apps/studio/client/styles/3-shell-maude.css +30 -0
- package/apps/studio/client/styles/4-components.css +72 -0
- package/apps/studio/cloud/endpoints.ts +104 -9
- package/apps/studio/collab/persistence.ts +29 -2
- package/apps/studio/config.schema.json +3 -3
- package/apps/studio/context.ts +41 -0
- package/apps/studio/dist/client.bundle.js +1545 -1545
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/gemma-models.ts +312 -14
- package/apps/studio/generation/prefs.ts +7 -2
- package/apps/studio/generation/runtime-probe.ts +50 -0
- package/apps/studio/generation/whisper-models.ts +124 -0
- package/apps/studio/hmr-broadcast.ts +67 -0
- package/apps/studio/http.ts +210 -110
- package/apps/studio/input-router.tsx +55 -2
- package/apps/studio/server.ts +11 -9
- package/apps/studio/sync/autocommit.ts +61 -2
- package/apps/studio/sync/cell-pairing.ts +174 -0
- package/apps/studio/sync/codec.ts +11 -5
- package/apps/studio/sync/index.ts +239 -26
- package/apps/studio/sync/limits.ts +49 -0
- package/apps/studio/sync/loopback.ts +21 -0
- package/apps/studio/sync/projection.ts +47 -12
- package/apps/studio/sync/supervisor.ts +178 -0
- package/apps/studio/test/cloud-endpoints.test.ts +326 -4
- package/apps/studio/test/csrf-write-guard.test.ts +19 -2
- package/apps/studio/test/gemma-models.test.ts +245 -0
- package/apps/studio/test/hmr-broadcast.test.ts +57 -1
- package/apps/studio/test/input-router.test.ts +95 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
- package/apps/studio/test/sync-autocommit.test.ts +47 -0
- package/apps/studio/test/sync-supervisor.test.ts +212 -0
- package/apps/studio/test/trusted-request-host.test.ts +66 -0
- package/apps/studio/test/whisper-setup.test.ts +97 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +9 -1
- package/cli/commands/kg.mjs +9 -2
- package/package.json +8 -8
- package/plugins/design/dependencies.json +21 -3
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
|
|
18
18
|
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
19
19
|
import { homedir } from 'node:os';
|
|
20
|
-
import { dirname, join } from 'node:path';
|
|
20
|
+
import { basename, dirname, join } from 'node:path';
|
|
21
21
|
|
|
22
22
|
import { saveHubCredential } from '../sync/hub-link.ts';
|
|
23
|
-
import { normalizeUrl } from '../sync/hubs-config.ts';
|
|
23
|
+
import { getHubToken, normalizeUrl } from '../sync/hubs-config.ts';
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Where Maude Cloud lives, resolved PER CALL rather than at module load.
|
|
@@ -109,18 +109,45 @@ async function cloudFetch(
|
|
|
109
109
|
|
|
110
110
|
interface Ctx {
|
|
111
111
|
paths: { repoRoot: string; designRoot: string };
|
|
112
|
+
/**
|
|
113
|
+
* The live sync runtime's owner (server.ts → sync/supervisor.ts). Optional:
|
|
114
|
+
* unit tests and any non-serving embedder construct a Ctx without one, and a
|
|
115
|
+
* missing supervisor degrades to exactly the old behaviour — linked on disk,
|
|
116
|
+
* syncing after the next start.
|
|
117
|
+
*/
|
|
118
|
+
syncControl?: {
|
|
119
|
+
restart(linkedHub?: {
|
|
120
|
+
url: string;
|
|
121
|
+
linkedAt: number;
|
|
122
|
+
syncTsx?: boolean;
|
|
123
|
+
}): Promise<{ syncing: boolean; canvases: number; reason?: string; detail?: string }>;
|
|
124
|
+
};
|
|
112
125
|
}
|
|
113
126
|
|
|
114
127
|
export function createCloudEndpoints(ctx: Ctx) {
|
|
115
128
|
return {
|
|
116
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Signed in? Who? And WHICH folder is asking? Cheap — reads two files,
|
|
131
|
+
* never the network.
|
|
132
|
+
*
|
|
133
|
+
* `project` + `linkedHub` are the local half of the answer, and they exist
|
|
134
|
+
* for one reason: a maude:// link names a CLOUD project, and the person
|
|
135
|
+
* confirming it deserves to see which LOCAL folder is about to be attached
|
|
136
|
+
* to it. Both are already-public facts (a directory name, and the
|
|
137
|
+
* token-free `linkedHub` this same module writes into a committed
|
|
138
|
+
* config.json) — no credential material widens by being reported here.
|
|
139
|
+
*/
|
|
117
140
|
status(): CloudEndpointResult {
|
|
118
141
|
const file = readCloudFile();
|
|
119
142
|
return {
|
|
120
143
|
status: 200,
|
|
121
|
-
json:
|
|
122
|
-
|
|
123
|
-
:
|
|
144
|
+
json: {
|
|
145
|
+
connected: !!file,
|
|
146
|
+
email: file?.email ?? null,
|
|
147
|
+
url: file ? file.url : cloudUrl(),
|
|
148
|
+
project: basename(ctx.paths.repoRoot),
|
|
149
|
+
linkedHub: readLinkedHub(),
|
|
150
|
+
},
|
|
124
151
|
};
|
|
125
152
|
},
|
|
126
153
|
|
|
@@ -211,7 +238,9 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
211
238
|
/**
|
|
212
239
|
* Attach THIS project to a cloud workspace: open → exchange at the cell →
|
|
213
240
|
* store the hub credential + linkedHub, exactly as `maude design link`
|
|
214
|
-
* would
|
|
241
|
+
* would — and then START SYNCING (linkToWorkspace cycles the live runtime
|
|
242
|
+
* through ctx.syncControl). Connecting is the whole gesture; there is no
|
|
243
|
+
* second step for the person to discover.
|
|
215
244
|
*/
|
|
216
245
|
async attach(projectId: string): Promise<CloudEndpointResult> {
|
|
217
246
|
const file = readCloudFile();
|
|
@@ -292,6 +321,34 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
292
321
|
},
|
|
293
322
|
};
|
|
294
323
|
|
|
324
|
+
/**
|
|
325
|
+
* The workspace this folder already answers to, if any. Address only.
|
|
326
|
+
*
|
|
327
|
+
* `credentialed` is the half that can be trusted. `config.json` is COMMITTED
|
|
328
|
+
* and travels with the repo, so a `linkedHub` in it is attacker-authorable:
|
|
329
|
+
* publish a template pointing at your own cell and the connect dialog would
|
|
330
|
+
* cheerfully print "this folder is already linked to <you>" — the strongest
|
|
331
|
+
* reassurance it can give, produced entirely by content the person merely
|
|
332
|
+
* opened (attacker pass 2026-08-04, B2). A stored hub credential for that
|
|
333
|
+
* address is the corroboration, because only a real sign-in writes one.
|
|
334
|
+
*/
|
|
335
|
+
function readLinkedHub(): { url: string; credentialed: boolean } | null {
|
|
336
|
+
try {
|
|
337
|
+
const cfg = JSON.parse(readFileSync(join(ctx.paths.designRoot, 'config.json'), 'utf8'));
|
|
338
|
+
const url = cfg?.linkedHub?.url;
|
|
339
|
+
if (typeof url !== 'string' || !url) return null;
|
|
340
|
+
let credentialed = false;
|
|
341
|
+
try {
|
|
342
|
+
credentialed = !!getHubToken(normalizeUrl(url));
|
|
343
|
+
} catch {
|
|
344
|
+
/* unreadable credential store → treat as uncorroborated */
|
|
345
|
+
}
|
|
346
|
+
return { url, credentialed };
|
|
347
|
+
} catch {
|
|
348
|
+
return null; // absent/malformed → simply not linked
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
295
352
|
/** The shared tail of every attach: cell exchange → credential + linkedHub. */
|
|
296
353
|
async function linkToWorkspace({
|
|
297
354
|
workspaceUrl,
|
|
@@ -338,9 +395,42 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
338
395
|
} catch {
|
|
339
396
|
/* absent/malformed → start minimal */
|
|
340
397
|
}
|
|
341
|
-
|
|
398
|
+
const linkedHub: { url: string; linkedAt: number; syncTsx?: boolean } = {
|
|
399
|
+
url: norm,
|
|
400
|
+
linkedAt: Date.now(),
|
|
401
|
+
};
|
|
402
|
+
// Carry a project-wide TSX opt-out across the re-link. Only the RESTRICTIVE
|
|
403
|
+
// direction is carried (`false`, never `true`): somebody who turned canvas
|
|
404
|
+
// BODIES off (DDR-072/DDR-079) did so deliberately, and silently re-enabling
|
|
405
|
+
// them on the next Connect would start uploading source they opted out of.
|
|
406
|
+
// It never mattered while nothing synced until a restart; it does now.
|
|
407
|
+
const prior = (cfg as { linkedHub?: { syncTsx?: boolean } }).linkedHub;
|
|
408
|
+
if (prior?.syncTsx === false) linkedHub.syncTsx = false;
|
|
409
|
+
cfg.linkedHub = linkedHub;
|
|
342
410
|
writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
|
|
343
411
|
|
|
412
|
+
// START SYNCING — do not hand the person a "restart the server" note and
|
|
413
|
+
// call the job done. The runtime captures linkedHub once at boot, so this
|
|
414
|
+
// cycles it in place, with the value we JUST wrote (never re-read from the
|
|
415
|
+
// committed config file). Best-effort: a supervisor-less embedder, or a
|
|
416
|
+
// runtime that declines, still leaves a correctly linked project behind.
|
|
417
|
+
let sync: { syncing: boolean; canvases: number; reason?: string; detail?: string } = {
|
|
418
|
+
syncing: false,
|
|
419
|
+
canvases: 0,
|
|
420
|
+
reason: 'no-supervisor',
|
|
421
|
+
detail: 'Restart Maude to start syncing.',
|
|
422
|
+
};
|
|
423
|
+
try {
|
|
424
|
+
sync = (await ctx.syncControl?.restart(linkedHub)) ?? sync;
|
|
425
|
+
} catch (err) {
|
|
426
|
+
sync = {
|
|
427
|
+
syncing: false,
|
|
428
|
+
canvases: 0,
|
|
429
|
+
reason: 'error',
|
|
430
|
+
detail: `Linked, but syncing could not start: ${(err as Error).message}`,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
344
434
|
return {
|
|
345
435
|
status: 200,
|
|
346
436
|
json: {
|
|
@@ -348,7 +438,12 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
348
438
|
url: norm,
|
|
349
439
|
role: role ?? null,
|
|
350
440
|
project: project ?? null,
|
|
351
|
-
|
|
441
|
+
sync,
|
|
442
|
+
// Kept for older clients (and the CLI) that read `note` — now it
|
|
443
|
+
// reports what actually happened instead of assigning homework.
|
|
444
|
+
note: sync.syncing
|
|
445
|
+
? `Linked — syncing ${sync.canvases} canvas${sync.canvases === 1 ? '' : 'es'}.`
|
|
446
|
+
: `Linked. ${sync.detail ?? 'Restart Maude to start syncing.'}`,
|
|
352
447
|
},
|
|
353
448
|
};
|
|
354
449
|
}
|
|
@@ -8,6 +8,9 @@ import * as Y from 'yjs';
|
|
|
8
8
|
|
|
9
9
|
import type { Api } from '../api.ts';
|
|
10
10
|
import type { Context } from '../context.ts';
|
|
11
|
+
// From the LEAF, never from `sync/codec.ts` — codec imports `Y_TYPES` from this
|
|
12
|
+
// file, so reaching for it here would close a cycle (see sync/limits.ts).
|
|
13
|
+
import { MAX_ANNOTATIONS_BYTES, MAX_COMMENTS_BYTES, withinByteCap } from '../sync/limits.ts';
|
|
11
14
|
import { ensureStateDir, type RoomCallbacks } from './room.ts';
|
|
12
15
|
|
|
13
16
|
/**
|
|
@@ -55,6 +58,26 @@ export interface PersistenceDeps {
|
|
|
55
58
|
* skipped when the Y type is empty / unset — the JSON file stays whatever
|
|
56
59
|
* the prior legacy write produced.
|
|
57
60
|
*/
|
|
61
|
+
/**
|
|
62
|
+
* DDR-064 pre-cutover checklist — cap the doc→disk lane for comments and
|
|
63
|
+
* annotations.
|
|
64
|
+
*
|
|
65
|
+
* The codec's `MAX_*_BYTES` guard the FILE→DOC direction. This is the other
|
|
66
|
+
* one, and until shared-doc it barely mattered: the room's doc was populated
|
|
67
|
+
* only by browsers on this machine. Under a shared doc it is populated by the
|
|
68
|
+
* hub, so an oversized array pushed by a peer (or by a hostile hub — DDR-054's
|
|
69
|
+
* threat model, §2d) would be materialized to this disk unbounded. Same ceiling
|
|
70
|
+
* as the import lane, so a value that could never be imported can never be
|
|
71
|
+
* written either.
|
|
72
|
+
*
|
|
73
|
+
* Refuses the WRITE, not the sync: the doc keeps the value and the peers keep
|
|
74
|
+
* converging. What is withheld is turning somebody else's blob into our disk.
|
|
75
|
+
* Shared with `sync/projection.ts`'s equivalent guard via `sync/limits.ts`.
|
|
76
|
+
*/
|
|
77
|
+
function withinCap(slug: string, lane: string, value: string, max: number): boolean {
|
|
78
|
+
return withinByteCap(`collab/${slug}`, lane, Buffer.byteLength(value, 'utf8'), max);
|
|
79
|
+
}
|
|
80
|
+
|
|
58
81
|
export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
|
|
59
82
|
const { ctx, api, fileForSlug } = deps;
|
|
60
83
|
const stateDir = ensureStateDir(ctx.paths.designRoot);
|
|
@@ -135,14 +158,18 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
|
|
|
135
158
|
const list = arr.toArray() as Parameters<Api['saveCommentsForFile']>[1];
|
|
136
159
|
if (list.length > 0) seenComments.add(slug);
|
|
137
160
|
if (list.length > 0 || seenComments.has(slug)) {
|
|
138
|
-
|
|
161
|
+
if (withinCap(slug, 'comments', JSON.stringify(list), MAX_COMMENTS_BYTES)) {
|
|
162
|
+
await api.saveCommentsForFile(file, list);
|
|
163
|
+
}
|
|
139
164
|
}
|
|
140
165
|
|
|
141
166
|
// Annotations — Y.Map.svg → annotations.svg file. Task 5.
|
|
142
167
|
const map = doc.getMap<unknown>(Y_TYPES.annotations);
|
|
143
168
|
const svg = map.get('svg');
|
|
144
169
|
if (typeof svg === 'string' && svg) {
|
|
145
|
-
|
|
170
|
+
if (withinCap(slug, 'annotations', svg, MAX_ANNOTATIONS_BYTES)) {
|
|
171
|
+
await api.saveAnnotations(file, svg);
|
|
172
|
+
}
|
|
146
173
|
}
|
|
147
174
|
}
|
|
148
175
|
|
|
@@ -259,12 +259,12 @@
|
|
|
259
259
|
},
|
|
260
260
|
"transcription": {
|
|
261
261
|
"type": "object",
|
|
262
|
-
"description": "Speech-to-text / subtitle engine preference (Task 2.6, DDR-164). The engine is
|
|
262
|
+
"description": "Speech-to-text / subtitle engine preference (Task 2.6, DDR-164). The engine is never switched behind your back: a chosen-but-unavailable engine errors clearly, and `auto` — a mode you SELECT — resolves to the best engine this machine is set up for while stating which one it picked. `maude design transcribe --provider` overrides this per-call.",
|
|
263
263
|
"properties": {
|
|
264
264
|
"provider": {
|
|
265
265
|
"type": "string",
|
|
266
|
-
"enum": ["whisper", "elevenlabs", "groq"],
|
|
267
|
-
"description": "Which engine transcribes when no --provider flag is given. `whisper` = local whisper.cpp (free, offline, no key); `elevenlabs` = ElevenLabs Scribe (cloud, key); `groq` = Groq Whisper (cloud, key). Absent ⇒ whisper.",
|
|
266
|
+
"enum": ["auto", "whisper", "elevenlabs", "groq"],
|
|
267
|
+
"description": "Which engine transcribes when no --provider flag is given. `auto` = use the cloud engine whose key is set (ElevenLabs Scribe first), else local whisper — the UI and the CLI both state what it resolved to; `whisper` = local whisper.cpp (free, offline, no key); `elevenlabs` = ElevenLabs Scribe (cloud, key, audio leaves the machine); `groq` = Groq Whisper (cloud, key). Absent ⇒ whisper.",
|
|
268
268
|
"default": "whisper"
|
|
269
269
|
},
|
|
270
270
|
"model": {
|
package/apps/studio/context.ts
CHANGED
|
@@ -182,6 +182,22 @@ export interface Context {
|
|
|
182
182
|
* gates behavior starting in Phase B.
|
|
183
183
|
*/
|
|
184
184
|
sharedDoc?: boolean;
|
|
185
|
+
/**
|
|
186
|
+
* The live sync runtime's owner, set by server.ts once it exists. Present so
|
|
187
|
+
* the cloud attach lane can START SYNCING the moment a person links a
|
|
188
|
+
* project, instead of leaving them holding a "restart the studio server"
|
|
189
|
+
* note with no button behind it. Structural type (not the SyncSupervisor
|
|
190
|
+
* import) so context.ts stays free of the sync module graph — tests and the
|
|
191
|
+
* non-serving entry points construct a Context without one.
|
|
192
|
+
*/
|
|
193
|
+
syncControl?: {
|
|
194
|
+
restart(linkedHub?: LinkedHub): Promise<{
|
|
195
|
+
syncing: boolean;
|
|
196
|
+
canvases: number;
|
|
197
|
+
reason?: string;
|
|
198
|
+
detail?: string;
|
|
199
|
+
}>;
|
|
200
|
+
};
|
|
185
201
|
}
|
|
186
202
|
|
|
187
203
|
function resolveRepoRoot(): string {
|
|
@@ -364,6 +380,12 @@ export function reloadConfig(ctx: Context): boolean {
|
|
|
364
380
|
// (syncTsx gating) drift out of step with the hub the socket is actually
|
|
365
381
|
// attached to — and a poisoned config must never re-point sync without a
|
|
366
382
|
// restart (DDR-149 fan-out review).
|
|
383
|
+
//
|
|
384
|
+
// This is a rule about the FILE, and it stays. The one path that may change
|
|
385
|
+
// the live link is `adoptLinkedHub` below: a person pressing Connect in
|
|
386
|
+
// trusted app chrome, whose hub credential this same process just stored.
|
|
387
|
+
// That path cycles the runtime itself (sync/supervisor.ts) instead of hoping
|
|
388
|
+
// a watcher notices.
|
|
367
389
|
if (JSON.stringify(next.linkedHub) !== JSON.stringify(ctx.cfg.linkedHub)) {
|
|
368
390
|
console.warn(' warn: linkedHub changed — not hot-reloadable, restart the server to apply.');
|
|
369
391
|
if (ctx.cfg.linkedHub === undefined) delete next.linkedHub;
|
|
@@ -389,3 +411,22 @@ export function reloadConfig(ctx: Context): boolean {
|
|
|
389
411
|
ctx.cfg.canvasGroups.find((g) => /system/i.test(g.path))?.path ?? 'system';
|
|
390
412
|
return true;
|
|
391
413
|
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Adopt a link the person just authorized — the ONE way `cfg.linkedHub` may
|
|
417
|
+
* change on a running server.
|
|
418
|
+
*
|
|
419
|
+
* The value is passed BY VALUE from the attach lane (cloud/endpoints.ts), which
|
|
420
|
+
* exchanged it against the configured cloud address and stored the matching hub
|
|
421
|
+
* credential moments earlier. It is deliberately NOT re-read from
|
|
422
|
+
* `.design/config.json`: that file is committed, shared, and writable by
|
|
423
|
+
* anything else on the machine, and re-reading it here would hand the
|
|
424
|
+
* file-watcher's refused capability (reloadConfig above) back through a side
|
|
425
|
+
* door. What the person consented to is what gets applied.
|
|
426
|
+
*
|
|
427
|
+
* Callers must cycle the sync runtime afterwards — `createSyncSupervisor`
|
|
428
|
+
* (sync/supervisor.ts) does both in one step, and is the only intended caller.
|
|
429
|
+
*/
|
|
430
|
+
export function adoptLinkedHub(ctx: Context, linkedHub: LinkedHub): void {
|
|
431
|
+
ctx.cfg.linkedHub = linkedHub;
|
|
432
|
+
}
|