@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.
Files changed (53) hide show
  1. package/apps/studio/annotations-layer.tsx +11 -1
  2. package/apps/studio/bin/_smart-frames.mjs +187 -20
  3. package/apps/studio/bin/_smart-frames.test.mjs +59 -4
  4. package/apps/studio/bin/_transcribe.mjs +40 -3
  5. package/apps/studio/bin/smoke.sh +7 -1
  6. package/apps/studio/canvas-build-sandbox.ts +101 -9
  7. package/apps/studio/canvas-build-worker.ts +7 -1
  8. package/apps/studio/canvas-build.ts +9 -1
  9. package/apps/studio/client/app.jsx +49 -3
  10. package/apps/studio/client/github.js +7 -0
  11. package/apps/studio/client/panels/CloudBar.jsx +505 -45
  12. package/apps/studio/client/panels/GitPanel.jsx +38 -25
  13. package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
  14. package/apps/studio/client/styles/3-shell-maude.css +30 -0
  15. package/apps/studio/client/styles/4-components.css +72 -0
  16. package/apps/studio/cloud/endpoints.ts +104 -9
  17. package/apps/studio/collab/persistence.ts +29 -2
  18. package/apps/studio/config.schema.json +3 -3
  19. package/apps/studio/context.ts +41 -0
  20. package/apps/studio/dist/client.bundle.js +1545 -1545
  21. package/apps/studio/dist/comment-mount.js +2 -2
  22. package/apps/studio/dist/styles.css +1 -1
  23. package/apps/studio/generation/gemma-models.ts +312 -14
  24. package/apps/studio/generation/prefs.ts +7 -2
  25. package/apps/studio/generation/runtime-probe.ts +50 -0
  26. package/apps/studio/generation/whisper-models.ts +124 -0
  27. package/apps/studio/hmr-broadcast.ts +67 -0
  28. package/apps/studio/http.ts +210 -110
  29. package/apps/studio/input-router.tsx +55 -2
  30. package/apps/studio/server.ts +11 -9
  31. package/apps/studio/sync/autocommit.ts +61 -2
  32. package/apps/studio/sync/cell-pairing.ts +174 -0
  33. package/apps/studio/sync/codec.ts +11 -5
  34. package/apps/studio/sync/index.ts +239 -26
  35. package/apps/studio/sync/limits.ts +49 -0
  36. package/apps/studio/sync/loopback.ts +21 -0
  37. package/apps/studio/sync/projection.ts +47 -12
  38. package/apps/studio/sync/supervisor.ts +178 -0
  39. package/apps/studio/test/cloud-endpoints.test.ts +326 -4
  40. package/apps/studio/test/csrf-write-guard.test.ts +19 -2
  41. package/apps/studio/test/gemma-models.test.ts +245 -0
  42. package/apps/studio/test/hmr-broadcast.test.ts +57 -1
  43. package/apps/studio/test/input-router.test.ts +95 -0
  44. package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
  45. package/apps/studio/test/sync-autocommit.test.ts +47 -0
  46. package/apps/studio/test/sync-supervisor.test.ts +212 -0
  47. package/apps/studio/test/trusted-request-host.test.ts +66 -0
  48. package/apps/studio/test/whisper-setup.test.ts +97 -0
  49. package/apps/studio/whats-new.json +45 -0
  50. package/apps/studio/ws.ts +9 -1
  51. package/cli/commands/kg.mjs +9 -2
  52. package/package.json +8 -8
  53. 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
- /** Signed in? Who? Cheap — reads the file, never the network. */
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: file
122
- ? { connected: true, email: file.email ?? null, url: file.url }
123
- : { connected: false, url: cloudUrl() },
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. The sync agent picks the link up on the next server start.
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
- cfg.linkedHub = { url: norm, linkedAt: Date.now() };
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
- note: 'Linked. Restart the studio server to start syncing.',
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
- await api.saveCommentsForFile(file, list);
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
- await api.saveAnnotations(file, svg);
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 an EXPLICIT user choice, never an automatic fallback: a chosen-but-unavailable engine errors clearly and Maude never silently switches to another. `maude design transcribe --provider` overrides this per-call.",
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": {
@@ -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
+ }