@1agh/maude 0.60.2 → 0.60.4

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 (38) hide show
  1. package/apps/studio/api.ts +14 -0
  2. package/apps/studio/client/app.jsx +44 -9
  3. package/apps/studio/client/panels/SyncPanel.jsx +53 -11
  4. package/apps/studio/context.ts +9 -0
  5. package/apps/studio/dist/client.bundle.js +525 -525
  6. package/apps/studio/http.ts +48 -7
  7. package/apps/studio/server.ts +11 -0
  8. package/apps/studio/sync/asset-pull.ts +210 -0
  9. package/apps/studio/sync/asset-push.ts +118 -75
  10. package/apps/studio/sync/connection-state.ts +23 -0
  11. package/apps/studio/sync/discovery.ts +139 -0
  12. package/apps/studio/sync/file-membership.ts +290 -0
  13. package/apps/studio/sync/file-pull.ts +330 -0
  14. package/apps/studio/sync/index.ts +995 -166
  15. package/apps/studio/sync/remote-docs.ts +98 -4
  16. package/apps/studio/sync/status.ts +25 -0
  17. package/apps/studio/sync/tombstone-apply.ts +131 -0
  18. package/apps/studio/test/cloud-managed-save-surfaces.test.ts +90 -0
  19. package/apps/studio/test/exporters/jobs.test.ts +10 -4
  20. package/apps/studio/test/git-cloud-posture.test.ts +11 -2
  21. package/apps/studio/test/sync-asset-pull.test.ts +161 -0
  22. package/apps/studio/test/sync-asset-push.test.ts +144 -3
  23. package/apps/studio/test/sync-attach-incremental.test.ts +527 -0
  24. package/apps/studio/test/sync-file-membership.test.ts +331 -0
  25. package/apps/studio/test/sync-file-pull.test.ts +333 -0
  26. package/apps/studio/test/sync-fresh-link-parity.test.ts +267 -0
  27. package/apps/studio/test/sync-panel-surface.test.ts +10 -0
  28. package/apps/studio/test/sync-remote-docs.test.ts +118 -8
  29. package/apps/studio/test/sync-resync-routes.test.ts +43 -0
  30. package/apps/studio/test/sync-status.test.ts +18 -0
  31. package/apps/studio/test/sync-supervisor.test.ts +4 -0
  32. package/apps/studio/test/sync-tombstone-apply.test.ts +111 -0
  33. package/apps/studio/test/sync-two-peer-discovery.test.ts +343 -0
  34. package/apps/studio/whats-new.json +18 -0
  35. package/cli/commands/doctor.mjs +141 -6
  36. package/cli/lib/gitignore-drift.mjs +149 -0
  37. package/cli/lib/gitignore-drift.test.mjs +156 -0
  38. package/package.json +8 -8
@@ -32,6 +32,29 @@ export interface RemoteDoc {
32
32
  bytes: number;
33
33
  }
34
34
 
35
+ /** One deletion the project has stated — see the hub's `tombstones.mjs`. */
36
+ export interface RemoteTombstone {
37
+ name: string;
38
+ deletedAt: number;
39
+ }
40
+
41
+ /**
42
+ * The whole listing: what the project HAS, and what it has DELETED.
43
+ *
44
+ * Both halves are needed, and for symmetric reasons. Presence alone made a
45
+ * hub-only document authority to create a local file — correct, and the reason
46
+ * a project arrives in full. Absence alone was un-representable, so a delete was
47
+ * indistinguishable from "this peer has not discovered it yet" and got refilled
48
+ * on the next tick. One request carries both.
49
+ */
50
+ export interface RemoteListing {
51
+ documents: RemoteDoc[];
52
+ /** Empty against a hub older than the tombstone route — never null, because
53
+ * "this hub cannot say" and "nothing was deleted" call for the same
54
+ * behaviour: change nothing on disk. */
55
+ tombstones: RemoteTombstone[];
56
+ }
57
+
35
58
  export interface RemoteDocDiff {
36
59
  /** Documents on both sides — the ones actually syncing. */
37
60
  shared: string[];
@@ -54,11 +77,11 @@ const LIST_TIMEOUT_MS = 6000;
54
77
  * reporting improvement, and making it load-bearing would trade a real feature
55
78
  * for a nicer message.
56
79
  */
57
- export async function fetchRemoteDocs(
80
+ export async function fetchRemoteListing(
58
81
  hubUrl: string,
59
82
  token: string,
60
83
  fetchImpl: typeof fetch = fetch
61
- ): Promise<RemoteDoc[] | null> {
84
+ ): Promise<RemoteListing | null> {
62
85
  try {
63
86
  const base = hubUrl.replace(/\/+$/, '');
64
87
  const res = await fetchImpl(`${base}/api/documents`, {
@@ -66,19 +89,64 @@ export async function fetchRemoteDocs(
66
89
  signal: AbortSignal.timeout(LIST_TIMEOUT_MS),
67
90
  });
68
91
  if (!res.ok) return null;
69
- const body = (await res.json()) as { documents?: unknown };
92
+ const body = (await res.json()) as { documents?: unknown; tombstones?: unknown };
70
93
  if (!Array.isArray(body?.documents)) return null;
71
- return body.documents
94
+ const documents = body.documents
72
95
  .filter(
73
96
  (d): d is RemoteDoc =>
74
97
  !!d && typeof (d as RemoteDoc).name === 'string' && (d as RemoteDoc).name.length > 0
75
98
  )
76
99
  .map((d) => ({ name: d.name, bytes: Number(d.bytes) || 0 }));
100
+ // A hub without the route omits the field entirely; a hostile one could send
101
+ // anything. Both land on "no deletions", which changes nothing on disk — the
102
+ // safe direction for a signal whose only effect is to REMOVE local files.
103
+ const tombstones = Array.isArray(body?.tombstones)
104
+ ? (body.tombstones as unknown[])
105
+ .filter(
106
+ (t): t is RemoteTombstone =>
107
+ !!t &&
108
+ typeof (t as RemoteTombstone).name === 'string' &&
109
+ (t as RemoteTombstone).name.length > 0
110
+ )
111
+ .map((t) => ({ name: t.name, deletedAt: Number(t.deletedAt) || 0 }))
112
+ : [];
113
+ return { documents, tombstones };
77
114
  } catch {
78
115
  return null;
79
116
  }
80
117
  }
81
118
 
119
+ /**
120
+ * Tell the hub a document is gone (`DELETE`) or exists again (`POST`).
121
+ *
122
+ * BEST-EFFORT, LIKE EVERY OTHER HUB CALL HERE. A hub that is old, offline or
123
+ * refusing means the local delete still happened — the canvas is in `_trash/`
124
+ * and this peer has released it. What is lost is only the PROPAGATION, and the
125
+ * peer retries the statement on its next poll for anything it still sees on the
126
+ * hub but no longer has on disk. Returning false rather than throwing keeps a
127
+ * failed network call from turning a successful local delete into an error the
128
+ * user has to interpret.
129
+ */
130
+ export async function stateDocumentGone(
131
+ hubUrl: string,
132
+ token: string,
133
+ docName: string,
134
+ opts: { revive?: boolean; fetchImpl?: typeof fetch } = {}
135
+ ): Promise<boolean> {
136
+ const fetchImpl = opts.fetchImpl ?? fetch;
137
+ try {
138
+ const base = hubUrl.replace(/\/+$/, '');
139
+ const res = await fetchImpl(`${base}/api/documents/${encodeURIComponent(docName)}`, {
140
+ method: opts.revive ? 'POST' : 'DELETE',
141
+ headers: { authorization: `Bearer ${token}` },
142
+ signal: AbortSignal.timeout(LIST_TIMEOUT_MS),
143
+ });
144
+ return res.ok;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
82
150
  /**
83
151
  * Diff the hub's documents against the ones this peer syncs.
84
152
  *
@@ -146,6 +214,32 @@ export function slugFromDocName(docName: string): string | null {
146
214
  return ns ? ns[1].toLowerCase() : null;
147
215
  }
148
216
 
217
+ /**
218
+ * The local slugs a tombstone set names — validated, deduplicated, safe.
219
+ *
220
+ * A tombstone is the one hub-supplied signal whose effect is to REMOVE work from
221
+ * a person's disk, so it runs through exactly the same name gate a pull does
222
+ * (`slugFromDocName`: whole-name match, no dots, no traversal, no extension
223
+ * smuggling) and anything that does not survive is dropped silently. A hub
224
+ * cannot name a file this way that it could not already have created.
225
+ *
226
+ * `known` is the set of slugs this peer actually holds; a tombstone for anything
227
+ * else is not an error, just nothing to do — that is the normal steady state
228
+ * once both sides have converged.
229
+ */
230
+ export function tombstonedSlugs(
231
+ tombstones: readonly RemoteTombstone[],
232
+ known: Iterable<string>
233
+ ): string[] {
234
+ const have = new Set(known);
235
+ const out = new Set<string>();
236
+ for (const t of tombstones) {
237
+ const slug = slugFromDocName(t.name);
238
+ if (slug && have.has(slug)) out.add(slug);
239
+ }
240
+ return [...out].sort();
241
+ }
242
+
149
243
  export interface PullTarget {
150
244
  slug: string;
151
245
  docName: string;
@@ -53,6 +53,22 @@ export interface SyncStatusPayload extends SyncStatusSnapshot {
53
53
  * payload as the doc counts so the Sync panel has one source, not two.
54
54
  */
55
55
  assets?: AssetPushProgress;
56
+ /**
57
+ * feature-sync-file-plane — Plane B's counts (additive; absent until the
58
+ * first flag-on file pull of a boot). `conflicts` names losers parked in
59
+ * `_trash/<rel>-conflict-<ts>` — the panel line points there, because a
60
+ * conflict a person cannot find is silent loss with extra steps.
61
+ */
62
+ files?: FilePlaneStatus;
63
+ }
64
+
65
+ export interface FilePlaneStatus {
66
+ /** Plane files present and equal after the last pass — the steady state. */
67
+ synced: number;
68
+ /** Cumulative files landed this boot. */
69
+ pulled: number;
70
+ /** Cumulative conflicts this boot (either winner); losers are in _trash/. */
71
+ conflicts: number;
56
72
  }
57
73
 
58
74
  export interface SyncStatusStoreOptions {
@@ -78,6 +94,9 @@ export interface SyncStatusStore {
78
94
  * broadcast. Kept in the store (not the monitor): assets are a push lane,
79
95
  * not a connection, and the monitor's state machine must not learn them. */
80
96
  updateAssets(progress: AssetPushProgress): void;
97
+ /** feature-sync-file-plane — merge Plane B counts + persist + broadcast.
98
+ * Same reasoning as `updateAssets`: a lane, not a connection. */
99
+ updateFiles(files: FilePlaneStatus): void;
81
100
  /** Current payload (defensive copy). */
82
101
  get(): SyncStatusPayload;
83
102
  }
@@ -102,6 +121,7 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
102
121
  };
103
122
 
104
123
  let assets: AssetPushProgress | undefined;
124
+ let files: FilePlaneStatus | undefined;
105
125
 
106
126
  function payload(): SyncStatusPayload {
107
127
  return {
@@ -111,6 +131,7 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
111
131
  conflicts: conflicts.slice(),
112
132
  ...(opts.sharedDoc ? { sharedDoc: true } : {}),
113
133
  ...(assets ? { assets } : {}),
134
+ ...(files ? { files } : {}),
114
135
  };
115
136
  }
116
137
 
@@ -142,6 +163,10 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
142
163
  assets = progress;
143
164
  flush();
144
165
  },
166
+ updateFiles(next) {
167
+ files = next;
168
+ flush();
169
+ },
145
170
  get: payload,
146
171
  };
147
172
  }
@@ -0,0 +1,131 @@
1
+ // Acting on a tombstone — the peer-side half of the delete lane.
2
+ //
3
+ // The hub states that a canvas is gone (`tombstones.mjs`); this is what a peer
4
+ // does about it. One rule governs the whole file:
5
+ //
6
+ // QUARANTINE, NEVER DELETE.
7
+ //
8
+ // This is the only code path where a HUB-SUPPLIED signal removes work from a
9
+ // person's disk, and the hub is untrusted to peers (DDR-054). Moving the bundle
10
+ // to `_trash/<slug>-deleted-<ts>/` is what makes that acceptable: a hub that is
11
+ // hostile, buggy, or simply confused about which project it is costs the user a
12
+ // trip to `_trash/`, never their work. The same posture, and the same directory,
13
+ // `migrate-flat-fallback.ts` already established for a peer-driven move.
14
+ //
15
+ // WHY THE SIDECARS TRAVEL AND THE HISTORY DOES NOT. The lanes that ARE the
16
+ // canvas — body, `.meta.json`, `.css`, `.annotations.svg` — move together, so
17
+ // what lands in `_trash/` is a restorable canvas rather than a body stripped of
18
+ // its annotations. `_history/<slug>/`, `_comments/`, `_canvas-state/` stay: they
19
+ // are per-machine runtime state (DDR-115), regenerated or irrelevant, and
20
+ // sweeping them would make a recoverable delete quietly lossy.
21
+
22
+ import { existsSync, mkdirSync, renameSync } from 'node:fs';
23
+ import path from 'node:path';
24
+
25
+ /**
26
+ * Park ONE file in `_trash/<rel>-conflict-<ts>` — the file plane's LWW
27
+ * loser's parking spot (feature-sync-file-plane), generalized out of
28
+ * `quarantineCanvas` below: same posture (quarantine, never delete; never
29
+ * throw — a failed quarantine costs the overwrite, not the sync runtime),
30
+ * scoped to a single file rather than a canvas's lane bundle.
31
+ *
32
+ * Returns the design-root-relative destination, or null when nothing moved —
33
+ * and the CALLER must treat null as "do not overwrite": a conflict loser
34
+ * that could not be parked is a conflict loser that stays.
35
+ */
36
+ export function quarantineFile(opts: {
37
+ designRoot: string;
38
+ /** Design-root-relative path of the file to park. */
39
+ rel: string;
40
+ now?: number;
41
+ log?: (line: string) => void;
42
+ }): string | null {
43
+ const { designRoot, rel } = opts;
44
+ const log = opts.log ?? ((line: string) => console.log(line));
45
+ const abs = path.join(designRoot, rel);
46
+ if (!existsSync(abs)) return null;
47
+ const trashedTo = `_trash/${rel}-conflict-${opts.now ?? Date.now()}`;
48
+ const trashAbs = path.join(designRoot, trashedTo);
49
+ try {
50
+ mkdirSync(path.dirname(trashAbs), { recursive: true });
51
+ renameSync(abs, trashAbs);
52
+ } catch (err) {
53
+ log(
54
+ `[sync/files] could not park ${rel} in _trash/: ${(err as Error).message} — keeping the local copy`
55
+ );
56
+ return null;
57
+ }
58
+ log(`[sync/files] conflict on ${rel} — the local copy moved to ${trashedTo}`);
59
+ return trashedTo;
60
+ }
61
+
62
+ export interface TombstoneMove {
63
+ slug: string;
64
+ /** Design-root-relative quarantine dir the canvas landed in. */
65
+ trashedTo: string;
66
+ /** Design-root-relative lanes that actually moved. */
67
+ moved: string[];
68
+ }
69
+
70
+ /** The absolute paths that make up one canvas on disk. */
71
+ export interface CanvasLanes {
72
+ html: string;
73
+ meta?: string;
74
+ css?: string;
75
+ annotations?: string;
76
+ }
77
+
78
+ /**
79
+ * Move one canvas's lanes into `_trash/`, best-effort.
80
+ *
81
+ * Returns the move, or null when there was nothing on disk to move (already
82
+ * gone — the idempotent steady state once both peers have converged) or when the
83
+ * quarantine directory could not be made. NEVER THROWS: a failed quarantine must
84
+ * cost the deletion, not the sync runtime that called it.
85
+ *
86
+ * `now` is injectable so a test does not have to reason about wall-clock.
87
+ */
88
+ export function quarantineCanvas(opts: {
89
+ designRoot: string;
90
+ slug: string;
91
+ lanes: CanvasLanes;
92
+ now?: number;
93
+ log?: (line: string) => void;
94
+ }): TombstoneMove | null {
95
+ const { designRoot, slug, lanes } = opts;
96
+ const log = opts.log ?? ((line: string) => console.log(line));
97
+ const present = [lanes.html, lanes.meta, lanes.css, lanes.annotations].filter(
98
+ (p): p is string => !!p && existsSync(p)
99
+ );
100
+ if (present.length === 0) return null;
101
+
102
+ const trashedTo = `_trash/${slug}-deleted-${opts.now ?? Date.now()}`;
103
+ const trashAbs = path.join(designRoot, trashedTo);
104
+ try {
105
+ mkdirSync(trashAbs, { recursive: true });
106
+ } catch (err) {
107
+ log(
108
+ `[sync/tombstone] could not quarantine ${slug}: ${(err as Error).message} — leaving it in place`
109
+ );
110
+ return null;
111
+ }
112
+
113
+ const moved: string[] = [];
114
+ for (const abs of present) {
115
+ try {
116
+ renameSync(abs, path.join(trashAbs, path.basename(abs)));
117
+ moved.push(path.relative(designRoot, abs));
118
+ } catch (err) {
119
+ // One stuck lane (an open handle on Windows, a permission quirk) must not
120
+ // abandon the lanes that CAN move — a half-moved canvas is still gone from
121
+ // the tree, and the remainder is named here so it can be found by hand.
122
+ log(
123
+ `[sync/tombstone] could not move ${path.basename(abs)} for ${slug}: ${(err as Error).message}`
124
+ );
125
+ }
126
+ }
127
+ if (moved.length === 0) return null;
128
+
129
+ log(`[sync/tombstone] ${slug} was deleted in the project — moved to ${trashedTo}/`);
130
+ return { slug, trashedTo, moved };
131
+ }
@@ -0,0 +1,90 @@
1
+ // DDR-218's withdrawal, applied to EVERY surface that reports a save state.
2
+ //
3
+ // The decision withdrew the working-tree half of the Changes surface whenever
4
+ // somebody else commits this project — a cloud cell (`historyOnly`, `cfg.cloud`)
5
+ // or the cell behind a linked+credentialed desktop repo (`cloudManaged`). It
6
+ // landed in GitPanel only. `app.jsx` went on computing the dirty count a second
7
+ // time, ungated, and handing it to the toolbar menu and the status-bar chip, so
8
+ // both shells kept rendering "N unsaved" — the exact claim the withdrawal exists
9
+ // to delete. Reported again 2026-08-14 ("89 unsaved changes on the cloud, 36 on
10
+ // the desktop"), which is what these assertions are here to stop.
11
+ //
12
+ // Source-level (sync-panel-surface style): what they pin is what would regress —
13
+ // someone re-deriving the posture at a call site, or adding a THIRD surface that
14
+ // reads `gitStatus.files.length` directly.
15
+
16
+ import { describe, expect, test } from 'bun:test';
17
+ import { readFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+
20
+ const STUDIO = join(import.meta.dir, '..');
21
+ const APP = readFileSync(join(STUDIO, 'client', 'app.jsx'), 'utf8');
22
+ const PANEL = readFileSync(join(STUDIO, 'client', 'panels', 'GitPanel.jsx'), 'utf8');
23
+
24
+ describe('one expression owns "who is saving this project"', () => {
25
+ test('both postures are named once, at the top level', () => {
26
+ expect(APP).toContain('const cellManaged = !!cfg.cloud;');
27
+ expect(APP).toContain('const cloudManaged = !cfg.cloud && !!cloudLinkedHub?.credentialed;');
28
+ expect(APP).toContain('const savingIsManaged = cellManaged || cloudManaged;');
29
+ });
30
+
31
+ test('the GitPanel call site consumes those names rather than re-deriving them', () => {
32
+ // Re-deriving is how the two halves drift apart in the first place.
33
+ expect(APP).toContain('historyOnly={cellManaged}');
34
+ expect(APP).toContain('cloudManaged={cloudManaged}');
35
+ });
36
+ });
37
+
38
+ describe('the dirty COUNT is withheld wherever saving is managed', () => {
39
+ test('unsavedCount is gated, not raw', () => {
40
+ expect(APP).toContain(
41
+ 'const unsavedCount = savingIsManaged ? 0 : gitStatus?.files?.length || 0;'
42
+ );
43
+ });
44
+
45
+ test('no surface reads the raw file count for display', () => {
46
+ // Exactly one `gitStatus?.files?.length` may exist, and it is the gated one
47
+ // above. A second occurrence is a new leak by construction.
48
+ const raw = APP.match(/gitStatus\?\.files\?\.length/g) ?? [];
49
+ expect(raw.length).toBe(1);
50
+ });
51
+
52
+ test('every changesCount consumer is fed the gated value', () => {
53
+ const feeds = APP.match(/changesCount=\{[^}]+\}/g) ?? [];
54
+ expect(feeds.length).toBeGreaterThan(0);
55
+ for (const feed of feeds) expect(feed).toBe('changesCount={unsavedCount}');
56
+ });
57
+ });
58
+
59
+ describe('the status-bar chip names the mechanism instead of a count', () => {
60
+ test('it receives the posture', () => {
61
+ expect(APP).toContain('savingIsManaged={savingIsManaged}');
62
+ expect(APP).toMatch(/savingIsManaged = false,/);
63
+ });
64
+
65
+ test('managed saving short-circuits the whole unsaved / to-publish ladder', () => {
66
+ expect(APP).toMatch(/\{savingIsManaged\s*\?/);
67
+ expect(APP).toContain("'cloud saving'");
68
+ });
69
+
70
+ test('"N to publish" is withheld too — Publish is withdrawn in that posture', () => {
71
+ // A gated count alone would fall through to the unpushed branch, which is
72
+ // the same local-git offer wearing a different label.
73
+ expect(APP).toContain('unpushed={savingIsManaged ? 0 : gitStatus?.unpushed || 0}');
74
+ });
75
+ });
76
+
77
+ describe('the panel half of DDR-218 is still intact', () => {
78
+ test('withdrawal covers both postures', () => {
79
+ expect(PANEL).toContain('const withdrawn = historyOnly || cloudManaged;');
80
+ });
81
+
82
+ test('the panel badge stays gated on it', () => {
83
+ expect(PANEL).toMatch(/!withdrawn && count > 0/);
84
+ });
85
+
86
+ test('the cloud-managed note keeps its testid and names the live mechanism', () => {
87
+ expect(PANEL).toContain('data-testid="git-cloud-managed"');
88
+ expect(PANEL).toMatch(/Cloud is saving/);
89
+ });
90
+ });
@@ -53,7 +53,11 @@ describe('exporters/jobs — concurrency gate', () => {
53
53
  let sawThirdQueued = false;
54
54
  let maxRunningObserved = 0;
55
55
  let allDone = false;
56
- const deadline = Date.now() + 4000;
56
+ // Generous: the assertion below is "the queue drains at all", not "it
57
+ // drains fast". The budget only has to outlast DEFLATE over the seeded
58
+ // sandbox on the slowest machine we run on — a budget tuned to one
59
+ // machine's disk speed fails there while the queue is working fine.
60
+ const deadline = Date.now() + 20_000;
57
61
  while (Date.now() < deadline && !allDone) {
58
62
  const jobs = await listJobs(port);
59
63
  const byId = new Map(jobs.map((j) => [j.id, j]));
@@ -74,7 +78,7 @@ describe('exporters/jobs — concurrency gate', () => {
74
78
  } finally {
75
79
  await killProc(proc);
76
80
  }
77
- });
81
+ }, 30_000);
78
82
  });
79
83
 
80
84
  describe('exporters/jobs — pending-queue cap (security fan-out, /flow:done)', () => {
@@ -109,7 +113,9 @@ describe('exporters/jobs — pending-queue cap (security fan-out, /flow:done)',
109
113
  // Once jobs drain below the cap, enqueue works again — this is a
110
114
  // capacity limit, not a permanent lockout.
111
115
  let drained = false;
112
- const deadline = Date.now() + 5000;
116
+ // Same reasoning as the concurrency gate above — budget for the slowest
117
+ // machine, since what's under test is that the cap lifts, not latency.
118
+ const deadline = Date.now() + 20_000;
113
119
  while (Date.now() < deadline && !drained) {
114
120
  const jobs = await listJobs(port);
115
121
  drained = jobs.every((j) => j.status === 'done' || j.status === 'failed');
@@ -121,7 +127,7 @@ describe('exporters/jobs — pending-queue cap (security fan-out, /flow:done)',
121
127
  } finally {
122
128
  await killProc(proc);
123
129
  }
124
- });
130
+ }, 30_000);
125
131
  });
126
132
 
127
133
  describe('exporters/jobs — list() + history under concurrency', () => {
@@ -37,8 +37,17 @@ describe('a cloud-linked repo shows ONE save mechanism (DDR-218)', () => {
37
37
  });
38
38
 
39
39
  test('the app gate is the CORROBORATED link, and the cell posture is untouched', () => {
40
- expect(APP).toContain('cloudManaged={!cfg.cloud && !!cloudLinkedHub?.credentialed}');
41
- expect(APP).toContain('historyOnly={!!cfg.cloud}');
40
+ // Hoisted to a single top-level definition (2026-08-14) so the toolbar and
41
+ // status bar read the SAME posture the panel does — see
42
+ // cloud-managed-save-surfaces.test.ts. The gate itself is unchanged, and
43
+ // `credentialed` is the load-bearing half: config.json alone is
44
+ // attacker-authorable (B2), so a link claim without corroboration must
45
+ // never reach this posture.
46
+ expect(APP).toContain('const cloudManaged = !cfg.cloud && !!cloudLinkedHub?.credentialed;');
47
+ expect(APP).toContain('const cellManaged = !!cfg.cloud;');
48
+ // …and the panel is fed from those names, not a re-derivation.
49
+ expect(APP).toContain('cloudManaged={cloudManaged}');
50
+ expect(APP).toContain('historyOnly={cellManaged}');
42
51
  });
43
52
 
44
53
  test('CloudBar lifts every link change the posture depends on', () => {
@@ -0,0 +1,161 @@
1
+ // The downward asset lane — see sync/asset-pull.ts.
2
+ //
3
+ // Regression cover for the reported "obrázky v annotations se nevykreslí":
4
+ // `<slug>.annotations.svg` synced, its `assets/<sha8>.png` did not, and the
5
+ // canvas rendered a broken-image glyph on every peer but the one that made it.
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import path from 'node:path';
11
+
12
+ import { pullAssets, referencedAssets } from '../sync/asset-pull.ts';
13
+
14
+ function project(files: Record<string, string>): string {
15
+ const root = mkdtempSync(path.join(tmpdir(), 'maude-asset-pull-'));
16
+ for (const [rel, body] of Object.entries(files)) {
17
+ const abs = path.join(root, rel);
18
+ mkdirSync(path.dirname(abs), { recursive: true });
19
+ writeFileSync(abs, body);
20
+ }
21
+ return root;
22
+ }
23
+
24
+ const annotation = (name: string) =>
25
+ `<svg xmlns="http://www.w3.org/2000/svg"><image data-tool="image" href="assets/${name}"/></svg>`;
26
+
27
+ /** A hub that serves every asset as three bytes. */
28
+ const servingFetch = (seen: string[] = []) =>
29
+ (async (url: string) => {
30
+ seen.push(String(url));
31
+ return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
32
+ }) as unknown as typeof fetch;
33
+
34
+ describe('referencedAssets', () => {
35
+ test('finds the image an annotation references', () => {
36
+ const root = project({ 'ui-board.annotations.svg': annotation('c0fa9c7f.png') });
37
+ expect(referencedAssets(root)).toEqual(['c0fa9c7f.png']);
38
+ });
39
+
40
+ test('reads canvas bodies and sidecars too, deduplicating', () => {
41
+ const root = project({
42
+ 'ui-board.annotations.svg': annotation('a.png'),
43
+ 'ui/card.tsx': `<img src="assets/b.jpg" /><img src="assets/a.png" />`,
44
+ 'ui/card.css': `.x { background: url(assets/c.webp) }`,
45
+ });
46
+ expect(referencedAssets(root)).toEqual(['a.png', 'b.jpg', 'c.webp']);
47
+ });
48
+
49
+ test('refuses a reference that could place a file outside assets/', () => {
50
+ // An annotation can itself have arrived from the hub, so a name found in
51
+ // one is untrusted input.
52
+ const root = project({
53
+ 'ui-board.annotations.svg': [
54
+ '<svg>',
55
+ '<image href="assets/../../etc/passwd"/>',
56
+ '<image href="assets/ok.png"/>',
57
+ '</svg>',
58
+ ].join(''),
59
+ });
60
+ expect(referencedAssets(root)).toEqual(['ok.png']);
61
+ });
62
+
63
+ test('refuses an extension outside the asset allowlist', () => {
64
+ const root = project({
65
+ 'ui/card.tsx': `"assets/payload.sh" "assets/x.tsx" "assets/real.png"`,
66
+ });
67
+ expect(referencedAssets(root)).toEqual(['real.png']);
68
+ });
69
+
70
+ test('ignores runtime directories — a trashed canvas wants nothing', () => {
71
+ const root = project({
72
+ '_trash/old-deleted-1/old.annotations.svg': annotation('ghost.png'),
73
+ '_history/ui-board/1.tsx': `"assets/older.png"`,
74
+ 'ui-board.annotations.svg': annotation('live.png'),
75
+ });
76
+ expect(referencedAssets(root)).toEqual(['live.png']);
77
+ });
78
+ });
79
+
80
+ describe('pullAssets', () => {
81
+ const opts = (root: string, fetchImpl: typeof fetch) => ({
82
+ designRoot: root,
83
+ hubUrl: 'https://hub.example/',
84
+ token: () => 'tok',
85
+ fetchImpl,
86
+ log: { log: () => {}, warn: () => {} },
87
+ });
88
+
89
+ test('fetches a referenced asset this machine does not have', async () => {
90
+ const root = project({ 'ui-board.annotations.svg': annotation('c0fa9c7f.png') });
91
+ const seen: string[] = [];
92
+ const res = await pullAssets(opts(root, servingFetch(seen)));
93
+
94
+ expect(res.pulled).toEqual(['c0fa9c7f.png']);
95
+ expect(seen).toEqual(['https://hub.example/assets/c0fa9c7f.png']);
96
+ expect(readFileSync(path.join(root, 'assets/c0fa9c7f.png'))).toEqual(Buffer.from([1, 2, 3]));
97
+ });
98
+
99
+ test('asks for nothing when every reference is already on disk', async () => {
100
+ const root = project({
101
+ 'ui-board.annotations.svg': annotation('have.png'),
102
+ 'assets/have.png': 'bytes',
103
+ });
104
+ let called = 0;
105
+ const counting = (async () => {
106
+ called += 1;
107
+ return new Response('x', { status: 200 });
108
+ }) as unknown as typeof fetch;
109
+
110
+ const res = await pullAssets(opts(root, counting));
111
+ expect(called).toBe(0);
112
+ expect(res.present).toBe(1);
113
+ expect(res.pulled).toEqual([]);
114
+ });
115
+
116
+ test('is idempotent — a second pass costs no requests', async () => {
117
+ const root = project({ 'ui-board.annotations.svg': annotation('x.png') });
118
+ await pullAssets(opts(root, servingFetch()));
119
+ const seen: string[] = [];
120
+ const second = await pullAssets(opts(root, servingFetch(seen)));
121
+ expect(seen).toEqual([]);
122
+ expect(second.pulled).toEqual([]);
123
+ });
124
+
125
+ test('a 404 is recorded and retried, never fatal', async () => {
126
+ // Ordinary: the peer that owns the bytes has not pushed them yet.
127
+ const root = project({ 'ui-board.annotations.svg': annotation('later.png') });
128
+ const missing = (async () => new Response('nope', { status: 404 })) as unknown as typeof fetch;
129
+ const res = await pullAssets(opts(root, missing));
130
+ expect(res.pulled).toEqual([]);
131
+ expect(res.failed).toEqual([{ name: 'later.png', reason: 'HTTP 404' }]);
132
+ expect(existsSync(path.join(root, 'assets/later.png'))).toBe(false);
133
+ });
134
+
135
+ test('a throwing hub is not fatal either', async () => {
136
+ const root = project({ 'ui-board.annotations.svg': annotation('x.png') });
137
+ const boom = (async () => {
138
+ throw new Error('offline');
139
+ }) as unknown as typeof fetch;
140
+ const res = await pullAssets(opts(root, boom));
141
+ expect(res.pulled).toEqual([]);
142
+ expect(res.failed[0]?.reason).toBe('offline');
143
+ });
144
+
145
+ test('refuses an empty body rather than writing a zero-byte image', async () => {
146
+ const root = project({ 'ui-board.annotations.svg': annotation('x.png') });
147
+ const empty = (async () =>
148
+ new Response(new Uint8Array([]), { status: 200 })) as unknown as typeof fetch;
149
+ const res = await pullAssets(opts(root, empty));
150
+ expect(res.pulled).toEqual([]);
151
+ expect(existsSync(path.join(root, 'assets/x.png'))).toBe(false);
152
+ });
153
+
154
+ test('leaves no .part file behind on success', async () => {
155
+ // The write is temp-then-rename so the dev server serving this very path
156
+ // never reads a half-written image.
157
+ const root = project({ 'ui-board.annotations.svg': annotation('x.png') });
158
+ await pullAssets(opts(root, servingFetch()));
159
+ expect(existsSync(path.join(root, 'assets/x.png.part'))).toBe(false);
160
+ });
161
+ });