@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.
- package/apps/studio/api.ts +14 -0
- package/apps/studio/client/app.jsx +44 -9
- package/apps/studio/client/panels/SyncPanel.jsx +53 -11
- package/apps/studio/context.ts +9 -0
- package/apps/studio/dist/client.bundle.js +525 -525
- package/apps/studio/http.ts +48 -7
- package/apps/studio/server.ts +11 -0
- package/apps/studio/sync/asset-pull.ts +210 -0
- package/apps/studio/sync/asset-push.ts +118 -75
- package/apps/studio/sync/connection-state.ts +23 -0
- package/apps/studio/sync/discovery.ts +139 -0
- package/apps/studio/sync/file-membership.ts +290 -0
- package/apps/studio/sync/file-pull.ts +330 -0
- package/apps/studio/sync/index.ts +995 -166
- package/apps/studio/sync/remote-docs.ts +98 -4
- package/apps/studio/sync/status.ts +25 -0
- package/apps/studio/sync/tombstone-apply.ts +131 -0
- package/apps/studio/test/cloud-managed-save-surfaces.test.ts +90 -0
- package/apps/studio/test/exporters/jobs.test.ts +10 -4
- package/apps/studio/test/git-cloud-posture.test.ts +11 -2
- package/apps/studio/test/sync-asset-pull.test.ts +161 -0
- package/apps/studio/test/sync-asset-push.test.ts +144 -3
- package/apps/studio/test/sync-attach-incremental.test.ts +527 -0
- package/apps/studio/test/sync-file-membership.test.ts +331 -0
- package/apps/studio/test/sync-file-pull.test.ts +333 -0
- package/apps/studio/test/sync-fresh-link-parity.test.ts +267 -0
- package/apps/studio/test/sync-panel-surface.test.ts +10 -0
- package/apps/studio/test/sync-remote-docs.test.ts +118 -8
- package/apps/studio/test/sync-resync-routes.test.ts +43 -0
- package/apps/studio/test/sync-status.test.ts +18 -0
- package/apps/studio/test/sync-supervisor.test.ts +4 -0
- package/apps/studio/test/sync-tombstone-apply.test.ts +111 -0
- package/apps/studio/test/sync-two-peer-discovery.test.ts +343 -0
- package/apps/studio/whats-new.json +18 -0
- package/cli/commands/doctor.mjs +141 -6
- package/cli/lib/gitignore-drift.mjs +149 -0
- package/cli/lib/gitignore-drift.test.mjs +156 -0
- 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
|
|
80
|
+
export async function fetchRemoteListing(
|
|
58
81
|
hubUrl: string,
|
|
59
82
|
token: string,
|
|
60
83
|
fetchImpl: typeof fetch = fetch
|
|
61
|
-
): Promise<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
+
});
|