@1agh/maude 1.3.0 → 1.3.1
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 +43 -2
- package/apps/studio/canvas-build.ts +49 -8
- package/apps/studio/canvas-edit.ts +77 -0
- package/apps/studio/canvas-list-watch.ts +33 -2
- package/apps/studio/client/app.jsx +97 -4
- package/apps/studio/client/canvas-url.js +39 -1
- package/apps/studio/client/styles/3-shell-maude.css +1 -0
- package/apps/studio/collab/awareness-bridge.ts +65 -14
- package/apps/studio/dist/client.bundle.js +449 -449
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/accepted-guard.ts +36 -0
- package/apps/studio/hmr-broadcast.ts +34 -1
- package/apps/studio/http.ts +11 -0
- package/apps/studio/managed-projects.ts +22 -0
- package/apps/studio/sync/index.ts +106 -12
- package/apps/studio/sync/presentation.ts +30 -0
- package/apps/studio/sync/status.ts +5 -1
- package/apps/studio/sync/transaction-client.ts +26 -0
- package/apps/studio/use-tool-mode.tsx +44 -0
- package/apps/studio/whats-new.json +36 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +48 -7
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Plan T34 / writer registry H09 — the checkout of a project that saves through
|
|
2
|
+
// accepted revisions (DDR-241) is a PROJECTION of the shared history, not a
|
|
3
|
+
// second author.
|
|
4
|
+
//
|
|
5
|
+
// A Git operation that rewrites the checkout's files — switching or adding a
|
|
6
|
+
// draft, discarding, getting latest, resolving a merge — would change files
|
|
7
|
+
// under the sync runtime, and its watcher would propose the result as this
|
|
8
|
+
// person's edit: a local Git graph silently overriding what the team accepted.
|
|
9
|
+
// In that mode these routes refuse, and say where the same intent lives:
|
|
10
|
+
// History (restore an earlier version as a new action). Commit, branch, push
|
|
11
|
+
// and fetch stay — they touch the Git graph, not the files (H10).
|
|
12
|
+
|
|
13
|
+
/** The `/_api/git/*` routes that rewrite checkout files. */
|
|
14
|
+
export const CHECKOUT_REWRITING_GIT_ROUTES = [
|
|
15
|
+
'/_api/git/checkout',
|
|
16
|
+
'/_api/git/fold',
|
|
17
|
+
'/_api/git/discard',
|
|
18
|
+
'/_api/git/pull',
|
|
19
|
+
'/_api/git/resolve',
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export const ACCEPTED_GIT_REFUSAL = {
|
|
23
|
+
ok: false,
|
|
24
|
+
code: 'accepted-project',
|
|
25
|
+
error:
|
|
26
|
+
'This project saves through its shared history, so its files cannot be rewritten from Git here. To go back to an earlier version, restore it from History — it becomes a new change everyone sees.',
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
/** A refusal when the linked project is in accepted-revisions mode, else null. */
|
|
30
|
+
export function refuseCheckoutRewrite(acceptedMode: (() => boolean) | undefined): Response | null {
|
|
31
|
+
if (acceptedMode?.() !== true) return null;
|
|
32
|
+
return Response.json(ACCEPTED_GIT_REFUSAL, {
|
|
33
|
+
status: 409,
|
|
34
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -46,8 +46,19 @@ export interface HmrMessage {
|
|
|
46
46
|
version: number;
|
|
47
47
|
/** Echo of `_lib`-scoped changes for debug + scope reasoning. */
|
|
48
48
|
scope?: 'lib' | 'canvas';
|
|
49
|
+
/**
|
|
50
|
+
* The change was written by sync — the project's version landing on this
|
|
51
|
+
* disk — not by this person's own edit. The iframe may skip a reload it
|
|
52
|
+
* believes is the echo of an optimistic edit it already shows; it must never
|
|
53
|
+
* skip one of these, because a teammate's change can ride the same write (a
|
|
54
|
+
* lost race re-applied onto theirs).
|
|
55
|
+
*/
|
|
56
|
+
remote?: boolean;
|
|
49
57
|
}
|
|
50
58
|
|
|
59
|
+
/** How long after a sync write its file's change still counts as remote. */
|
|
60
|
+
export const REMOTE_WRITE_WINDOW_MS = 3_000;
|
|
61
|
+
|
|
51
62
|
export interface HmrBroadcaster {
|
|
52
63
|
/** Stop subscribing to fs-watch events. */
|
|
53
64
|
stop(): void;
|
|
@@ -103,9 +114,28 @@ export function createHmrBroadcaster(
|
|
|
103
114
|
});
|
|
104
115
|
}
|
|
105
116
|
|
|
117
|
+
// Files sync just wrote (see HmrMessage.remote), with the time of the write.
|
|
118
|
+
const projectedAt = new Map<string, number>();
|
|
119
|
+
const isRemote = (rel: string | undefined): boolean => {
|
|
120
|
+
if (!rel) return false;
|
|
121
|
+
const at = projectedAt.get(rel);
|
|
122
|
+
return at !== undefined && Date.now() - at < REMOTE_WRITE_WINDOW_MS;
|
|
123
|
+
};
|
|
124
|
+
const offProjected = ctx.bus.on('sync:projected', (rel: unknown) => {
|
|
125
|
+
if (typeof rel !== 'string' || !rel) return;
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
projectedAt.set(rel.replace(/\\/g, '/'), now);
|
|
128
|
+
if (projectedAt.size > 256) {
|
|
129
|
+
for (const [k, at] of projectedAt)
|
|
130
|
+
if (now - at >= REMOTE_WRITE_WINDOW_MS) projectedAt.delete(k);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
106
134
|
function enqueue(msg: HmrMessage) {
|
|
107
135
|
const key = msg.mode === 'hard' ? GLOBAL_KEY : (msg.file ?? GLOBAL_KEY);
|
|
108
136
|
const prev = pendingByKey.get(key);
|
|
137
|
+
// Coalescing never loses "a teammate's change is in here".
|
|
138
|
+
if (prev?.remote) msg.remote = true;
|
|
109
139
|
// Same-key coalescing keeps the strongest mode (refreshing the payload for
|
|
110
140
|
// equal rank, so the latest version token wins).
|
|
111
141
|
if (!prev || rank[msg.mode] >= rank[prev.mode]) pendingByKey.set(key, msg);
|
|
@@ -115,12 +145,15 @@ export function createHmrBroadcaster(
|
|
|
115
145
|
|
|
116
146
|
const offAny = ctx.bus.on('fs:any', (rel: string) => {
|
|
117
147
|
const msg = classify(rel);
|
|
118
|
-
if (msg)
|
|
148
|
+
if (!msg) return;
|
|
149
|
+
if (isRemote(rel.replace(/\\/g, '/')) || isRemote(msg.file)) msg.remote = true;
|
|
150
|
+
enqueue(msg);
|
|
119
151
|
});
|
|
120
152
|
|
|
121
153
|
return {
|
|
122
154
|
stop() {
|
|
123
155
|
offAny();
|
|
156
|
+
offProjected();
|
|
124
157
|
if (pending) clearTimeout(pending);
|
|
125
158
|
pending = null;
|
|
126
159
|
pendingByKey.clear();
|
package/apps/studio/http.ts
CHANGED
|
@@ -98,6 +98,7 @@ import {
|
|
|
98
98
|
resolveAutoEngine,
|
|
99
99
|
whisperSetup,
|
|
100
100
|
} from './generation/whisper-models.ts';
|
|
101
|
+
import { refuseCheckoutRewrite } from './git/accepted-guard.ts';
|
|
101
102
|
import { createGitEndpoints } from './git/endpoints.ts';
|
|
102
103
|
import { gitShowFile } from './git/service.ts';
|
|
103
104
|
import { createGitHubEndpoints } from './github/endpoints.ts';
|
|
@@ -2632,6 +2633,8 @@ export function createHttp(
|
|
|
2632
2633
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
2633
2634
|
if (!isTrustedRequestHost(req))
|
|
2634
2635
|
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2636
|
+
const rewrite = refuseCheckoutRewrite(ctx.syncControl?.current?.()?.acceptedMode);
|
|
2637
|
+
if (rewrite) return rewrite;
|
|
2635
2638
|
const body = await readJson<unknown>(req, 8 * 1024);
|
|
2636
2639
|
return gitJson(await gitApi.checkout(body));
|
|
2637
2640
|
},
|
|
@@ -2644,6 +2647,8 @@ export function createHttp(
|
|
|
2644
2647
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
2645
2648
|
if (!isTrustedRequestHost(req))
|
|
2646
2649
|
return new Response('adding a draft requires a local request', { status: 403 });
|
|
2650
|
+
const rewrite = refuseCheckoutRewrite(ctx.syncControl?.current?.()?.acceptedMode);
|
|
2651
|
+
if (rewrite) return rewrite;
|
|
2647
2652
|
const body = await readJson<unknown>(req, 8 * 1024);
|
|
2648
2653
|
return gitJson(await gitApi.fold(body));
|
|
2649
2654
|
},
|
|
@@ -2676,6 +2681,8 @@ export function createHttp(
|
|
|
2676
2681
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
2677
2682
|
if (!isTrustedRequestHost(req))
|
|
2678
2683
|
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2684
|
+
const rewrite = refuseCheckoutRewrite(ctx.syncControl?.current?.()?.acceptedMode);
|
|
2685
|
+
if (rewrite) return rewrite;
|
|
2679
2686
|
const body = await readJson<unknown>(req, 256 * 1024);
|
|
2680
2687
|
return gitJson(await gitApi.discard(body));
|
|
2681
2688
|
},
|
|
@@ -2700,6 +2707,8 @@ export function createHttp(
|
|
|
2700
2707
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
2701
2708
|
if (!isTrustedRequestHost(req))
|
|
2702
2709
|
return new Response('get latest requires a local request', { status: 403 });
|
|
2710
|
+
const rewrite = refuseCheckoutRewrite(ctx.syncControl?.current?.()?.acceptedMode);
|
|
2711
|
+
if (rewrite) return rewrite;
|
|
2703
2712
|
const body = await readJson<unknown>(req, 8 * 1024);
|
|
2704
2713
|
return gitJson(await gitApi.pull(body));
|
|
2705
2714
|
},
|
|
@@ -2714,6 +2723,8 @@ export function createHttp(
|
|
|
2714
2723
|
return new Response('cross-origin write rejected', { status: 403 });
|
|
2715
2724
|
if (!isTrustedRequestHost(req))
|
|
2716
2725
|
return new Response('resolve requires a local request', { status: 403 });
|
|
2726
|
+
const rewrite = refuseCheckoutRewrite(ctx.syncControl?.current?.()?.acceptedMode);
|
|
2727
|
+
if (rewrite) return rewrite;
|
|
2717
2728
|
const body = await readJson<unknown>(req, 8 * 1024);
|
|
2718
2729
|
return gitJson(await gitApi.resolve(body));
|
|
2719
2730
|
},
|
|
@@ -69,6 +69,15 @@ export function groupsFromBootstrap(boot: {
|
|
|
69
69
|
return [...out].slice(0, 32);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
function sameServer(a: string | undefined, b: string): boolean {
|
|
73
|
+
if (!a) return false;
|
|
74
|
+
try {
|
|
75
|
+
return normalizeUrl(a) === normalizeUrl(b);
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
72
81
|
async function bootstrapOf(url: string, token: string): Promise<Record<string, unknown> | null> {
|
|
73
82
|
try {
|
|
74
83
|
const res = await fetch(`${url}/api/projects/current/v1/bootstrap`, {
|
|
@@ -135,6 +144,19 @@ export async function prepareManagedProject(
|
|
|
135
144
|
return { ok: false, error: 'The sign-in could not be saved on this computer.', status: 500 };
|
|
136
145
|
const boot = await bootstrapOf(serverUrl, token);
|
|
137
146
|
if (boot && typeof boot.projectId === 'string') projectId ??= boot.projectId;
|
|
147
|
+
// SIGN IN AGAIN (T20): a new credential for the server this studio is
|
|
148
|
+
// already syncing with. Reopening the same project attaches to this very
|
|
149
|
+
// process (the shell never starts a second one for a running project), and
|
|
150
|
+
// the running runtime still holds the credential the project refused — so
|
|
151
|
+
// cycle it onto the one just stored. The link is unchanged and never re-read
|
|
152
|
+
// from disk; only the credential is new, and this process wrote it.
|
|
153
|
+
if (input.kind !== 'known-hub' && sameServer(ctx.cfg?.linkedHub?.url, serverUrl)) {
|
|
154
|
+
try {
|
|
155
|
+
await ctx.syncControl?.restart();
|
|
156
|
+
} catch (err) {
|
|
157
|
+
console.error('[managed] could not restart sync on the new sign-in:', err);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
138
160
|
const host = new URL(serverUrl).host;
|
|
139
161
|
projectId ??= host;
|
|
140
162
|
if (!/^[A-Za-z0-9._-]{1,128}$/.test(projectId)) projectId = host.replace(/[^A-Za-z0-9._-]/g, '-');
|
|
@@ -1222,6 +1222,9 @@ export function createSyncRuntime(
|
|
|
1222
1222
|
/** The outbound delete-lane subscriptions — see `noteToHub`. */
|
|
1223
1223
|
let deletedUnsub: (() => void) | null = null;
|
|
1224
1224
|
let createdUnsub: (() => void) | null = null;
|
|
1225
|
+
/** Folders made outside the app while running — see `proposeLocalFolders`. */
|
|
1226
|
+
let foldersUnsub: (() => void) | null = null;
|
|
1227
|
+
let foldersTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1225
1228
|
/** Periodic remote-document poll — the hub-side half of discovery. */
|
|
1226
1229
|
let remotePollTimer: ReturnType<typeof setInterval> | null = null;
|
|
1227
1230
|
/** The stall watchdog's tick — see `stallCheck` in `start()`. */
|
|
@@ -1312,6 +1315,15 @@ export function createSyncRuntime(
|
|
|
1312
1315
|
* work), and passes are floored `MIN_PASS_INTERVAL_MS` apart.
|
|
1313
1316
|
*/
|
|
1314
1317
|
let planePassInFlight = false;
|
|
1318
|
+
/**
|
|
1319
|
+
* News that arrived WHILE a pass ran. The running pass may already have read
|
|
1320
|
+
* the disk and the hub's listing, so it cannot carry a file written — or a
|
|
1321
|
+
* row a poke announced — after that point; absorbing the request silently
|
|
1322
|
+
* parked such a file until the next 20 s tick (plan T17/L03: a new image
|
|
1323
|
+
* took 15–20 s to reach a teammate while an edit took one). One more pass,
|
|
1324
|
+
* floored like any other, answers it.
|
|
1325
|
+
*/
|
|
1326
|
+
let planePassAgain = false;
|
|
1315
1327
|
let lastPlanePassAt = 0;
|
|
1316
1328
|
let planePassWaiter: Promise<void> | null = null;
|
|
1317
1329
|
let lastPlaneResult: import('./file-plane.ts').FilePlaneResult | null = null;
|
|
@@ -1323,7 +1335,10 @@ export function createSyncRuntime(
|
|
|
1323
1335
|
// A pass already running IS this pass: it reads the same disk and the same
|
|
1324
1336
|
// cursor, so the caller waits for its answer rather than racing it. The
|
|
1325
1337
|
// poll's `await` therefore still means "a pass has happened".
|
|
1326
|
-
if (planePassInFlight)
|
|
1338
|
+
if (planePassInFlight) {
|
|
1339
|
+
planePassAgain = true;
|
|
1340
|
+
return await (planePassWaiter ?? Promise.resolve());
|
|
1341
|
+
}
|
|
1327
1342
|
const since = Date.now() - lastPlanePassAt;
|
|
1328
1343
|
// The floor governs POKES. The 20 s poll and the explicit `pullRemoteNow`
|
|
1329
1344
|
// seam are already bounded by their own callers, and silently deferring a
|
|
@@ -1346,6 +1361,10 @@ export function createSyncRuntime(
|
|
|
1346
1361
|
} finally {
|
|
1347
1362
|
planePassInFlight = false;
|
|
1348
1363
|
planePassWaiter = null;
|
|
1364
|
+
if (planePassAgain) {
|
|
1365
|
+
planePassAgain = false;
|
|
1366
|
+
schedulePlanePass();
|
|
1367
|
+
}
|
|
1349
1368
|
}
|
|
1350
1369
|
})();
|
|
1351
1370
|
planePassWaiter = run;
|
|
@@ -1867,9 +1886,33 @@ export function createSyncRuntime(
|
|
|
1867
1886
|
return abs;
|
|
1868
1887
|
};
|
|
1869
1888
|
const designRelPosix = ctx.paths.designRel.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
|
|
1889
|
+
/**
|
|
1890
|
+
* A folder the project removed or moved away is taken off this disk only
|
|
1891
|
+
* once it is empty — but the canvases inside leave AFTER the pull that
|
|
1892
|
+
* learned of the action (their moves and deletions land through their own
|
|
1893
|
+
* documents a beat later). Waiting for the next poke or 20 s tick left the
|
|
1894
|
+
* old folder in a teammate's tree for up to 20 s (plan T31/L02). So a
|
|
1895
|
+
* deferred removal asks for another pass shortly, a bounded number of times.
|
|
1896
|
+
*/
|
|
1897
|
+
let dirsRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1898
|
+
let dirsRetries = 0;
|
|
1899
|
+
const DIRS_RETRY_MS = 1_500;
|
|
1900
|
+
const DIRS_RETRY_MAX = 12;
|
|
1901
|
+
function retryDeferredDirs(): void {
|
|
1902
|
+
if (stopped || dirsRetryTimer !== null || dirsRetries >= DIRS_RETRY_MAX) return;
|
|
1903
|
+
dirsRetries += 1;
|
|
1904
|
+
dirsRetryTimer = setTimeout(() => {
|
|
1905
|
+
dirsRetryTimer = null;
|
|
1906
|
+
if (stopped || !acceptedOn()) return;
|
|
1907
|
+
if (documentDiscovery) documentDiscovery.schedule();
|
|
1908
|
+
else applyProjectDirs(acceptedLink?.manifest?.dirs ?? []);
|
|
1909
|
+
}, DIRS_RETRY_MS);
|
|
1910
|
+
dirsRetryTimer.unref?.();
|
|
1911
|
+
}
|
|
1870
1912
|
function applyProjectDirs(dirs: readonly string[]): void {
|
|
1871
1913
|
const next = new Set(dirs);
|
|
1872
1914
|
let changed = false;
|
|
1915
|
+
let deferred = 0;
|
|
1873
1916
|
for (const rel of next) {
|
|
1874
1917
|
if (knownProjectDirs.has(rel)) continue;
|
|
1875
1918
|
const abs = projectDirAbs(rel);
|
|
@@ -1903,8 +1946,11 @@ export function createSyncRuntime(
|
|
|
1903
1946
|
continue;
|
|
1904
1947
|
}
|
|
1905
1948
|
// Canvases inside leave through their own deletion/move first; until
|
|
1906
|
-
// then the folder stays and
|
|
1907
|
-
if (entries.some((e) => e !== '.gitkeep'))
|
|
1949
|
+
// then the folder stays and a pass shortly tries again.
|
|
1950
|
+
if (entries.some((e) => e !== '.gitkeep')) {
|
|
1951
|
+
deferred += 1;
|
|
1952
|
+
continue;
|
|
1953
|
+
}
|
|
1908
1954
|
try {
|
|
1909
1955
|
for (const e of readdirSync(abs)) rmSync(path.join(abs, e), { force: true });
|
|
1910
1956
|
rmdirSync(abs);
|
|
@@ -1920,6 +1966,8 @@ export function createSyncRuntime(
|
|
|
1920
1966
|
changed = true;
|
|
1921
1967
|
}
|
|
1922
1968
|
if (changed) saveProjectDirs();
|
|
1969
|
+
if (deferred > 0) retryDeferredDirs();
|
|
1970
|
+
else dirsRetries = 0;
|
|
1923
1971
|
}
|
|
1924
1972
|
|
|
1925
1973
|
/**
|
|
@@ -1975,9 +2023,17 @@ export function createSyncRuntime(
|
|
|
1975
2023
|
* project as ONE action at cold start. Only additive: a folder the manifest
|
|
1976
2024
|
* lists and this disk lacks is never proposed as a deletion from here.
|
|
1977
2025
|
*/
|
|
2026
|
+
let folderProposal: Promise<void> | null = null;
|
|
2027
|
+
let folderProposalAgain = false;
|
|
1978
2028
|
function proposeLocalFolders(): void {
|
|
1979
2029
|
if (!acceptedLink) return;
|
|
1980
|
-
|
|
2030
|
+
// One proposal at a time: a second walk while the first is unanswered
|
|
2031
|
+
// would offer the same folders twice.
|
|
2032
|
+
if (folderProposal) {
|
|
2033
|
+
folderProposalAgain = true;
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
const listed = new Set([...(acceptedLink.manifest?.dirs ?? []), ...knownProjectDirs]);
|
|
1981
2037
|
const found: string[] = [];
|
|
1982
2038
|
const walk = (abs: string, rel: string, depth: number) => {
|
|
1983
2039
|
if (depth > 16 || found.length >= 200) return;
|
|
@@ -2006,13 +2062,25 @@ export function createSyncRuntime(
|
|
|
2006
2062
|
if (rel) walk(path.join(ctx.paths.designRoot, ...rel.split('/')), rel, 1);
|
|
2007
2063
|
}
|
|
2008
2064
|
if (found.length === 0) return;
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2065
|
+
folderProposal = acceptedLink
|
|
2066
|
+
.dirsCreate(found)
|
|
2067
|
+
.then((r) => {
|
|
2068
|
+
if (r.status === 'accepted') {
|
|
2069
|
+
for (const d of found) knownProjectDirs.add(d);
|
|
2070
|
+
saveProjectDirs();
|
|
2071
|
+
console.log(`[sync] added ${found.length} local folder(s) to the project.`);
|
|
2072
|
+
}
|
|
2073
|
+
})
|
|
2074
|
+
.catch(() => {
|
|
2075
|
+
/* the next folder event or cold start offers them again */
|
|
2076
|
+
})
|
|
2077
|
+
.finally(() => {
|
|
2078
|
+
folderProposal = null;
|
|
2079
|
+
if (folderProposalAgain) {
|
|
2080
|
+
folderProposalAgain = false;
|
|
2081
|
+
proposeLocalFolders();
|
|
2082
|
+
}
|
|
2083
|
+
});
|
|
2016
2084
|
}
|
|
2017
2085
|
|
|
2018
2086
|
function proposeFolder(
|
|
@@ -3535,7 +3603,15 @@ export function createSyncRuntime(
|
|
|
3535
3603
|
// the HMR broadcaster's per-file coalescing collapses the pair
|
|
3536
3604
|
// into one `canvas-hmr`. The projector's own echo guard drops the
|
|
3537
3605
|
// resulting file→doc read, so this cannot loop.
|
|
3538
|
-
|
|
3606
|
+
onWrote: (abs: string) => {
|
|
3607
|
+
// Tell the canvas layer this write carries the PROJECT's
|
|
3608
|
+
// version — possibly a teammate's change merged under this
|
|
3609
|
+
// person's own — so an open canvas never discards its reload
|
|
3610
|
+
// as the echo of an optimistic edit (hmr-broadcast `remote`).
|
|
3611
|
+
const rel = path.relative(ctx.paths.designRoot, abs).split(path.sep).join('/');
|
|
3612
|
+
if (rel && !rel.startsWith('..')) ctx.bus.emit('sync:projected', rel);
|
|
3613
|
+
if (cellPairing) announceWrite(abs);
|
|
3614
|
+
},
|
|
3539
3615
|
});
|
|
3540
3616
|
projection.start();
|
|
3541
3617
|
projections.set(canvas.slug, projection);
|
|
@@ -3818,6 +3894,18 @@ export function createSyncRuntime(
|
|
|
3818
3894
|
});
|
|
3819
3895
|
discoveryRescan = rescan;
|
|
3820
3896
|
discoveryUnsub = ctx.bus.on('canvas-list-update', () => rescan.schedule());
|
|
3897
|
+
// A folder made OUTSIDE the app while it runs — an agent's `mkdir` plus
|
|
3898
|
+
// `.gitkeep`, Finder, a `git checkout` — joins the project the way a cold
|
|
3899
|
+
// start adds one: additively, never as a deletion. Without this it stayed
|
|
3900
|
+
// on this machine until the next launch.
|
|
3901
|
+
foldersUnsub = ctx.bus.on('fs:any', (rel: unknown) => {
|
|
3902
|
+
if (typeof rel !== 'string' || !rel.endsWith('/.gitkeep') || !acceptedOn()) return;
|
|
3903
|
+
if (foldersTimer) clearTimeout(foldersTimer);
|
|
3904
|
+
foldersTimer = setTimeout(() => {
|
|
3905
|
+
foldersTimer = null;
|
|
3906
|
+
if (acceptedOn()) proposeLocalFolders();
|
|
3907
|
+
}, 300);
|
|
3908
|
+
});
|
|
3821
3909
|
|
|
3822
3910
|
// The OUTBOUND half of the delete lane. `api.ts` emits these two only from
|
|
3823
3911
|
// its privileged create/delete routes, never from the filesystem watcher —
|
|
@@ -4518,6 +4606,12 @@ export function createSyncRuntime(
|
|
|
4518
4606
|
attachOne = null;
|
|
4519
4607
|
discoveryUnsub?.();
|
|
4520
4608
|
discoveryUnsub = null;
|
|
4609
|
+
foldersUnsub?.();
|
|
4610
|
+
foldersUnsub = null;
|
|
4611
|
+
if (foldersTimer) clearTimeout(foldersTimer);
|
|
4612
|
+
foldersTimer = null;
|
|
4613
|
+
if (dirsRetryTimer) clearTimeout(dirsRetryTimer);
|
|
4614
|
+
dirsRetryTimer = null;
|
|
4521
4615
|
deletedUnsub?.();
|
|
4522
4616
|
deletedUnsub = null;
|
|
4523
4617
|
createdUnsub?.();
|
|
@@ -427,6 +427,27 @@ export function syncPresentation(
|
|
|
427
427
|
};
|
|
428
428
|
}
|
|
429
429
|
|
|
430
|
+
// Accepted revisions: the project answered "sign in first" to a change —
|
|
431
|
+
// the sign-in was revoked or its person removed. The change is kept on this
|
|
432
|
+
// device and retried, but it cannot reach anyone until someone signs in
|
|
433
|
+
// again, so this is a refusal, never "Saving … check your connection".
|
|
434
|
+
const refusedSignIn = readAcceptedRefused(status.accepted);
|
|
435
|
+
if (refusedSignIn) {
|
|
436
|
+
const n = refusedSignIn.pending;
|
|
437
|
+
return {
|
|
438
|
+
phase: 'refused',
|
|
439
|
+
online: false,
|
|
440
|
+
label: 'sign in again',
|
|
441
|
+
title:
|
|
442
|
+
`${project} no longer accepts your sign-in — ` +
|
|
443
|
+
(n > 0
|
|
444
|
+
? `${n} change${n === 1 ? ' is' : 's are'} kept on this device and not shared.`
|
|
445
|
+
: 'your changes are not shared.'),
|
|
446
|
+
next: 'Sign in again to share them.',
|
|
447
|
+
names: [],
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
430
451
|
// A refused SOURCE change outranks an unreachable hub for the same reason a
|
|
431
452
|
// refusal does: it is sticky, and it is the part that needs a person.
|
|
432
453
|
// (With the hub reachable, the same sentence lists every other lane too.)
|
|
@@ -634,6 +655,15 @@ function readAcceptedPending(
|
|
|
634
655
|
};
|
|
635
656
|
}
|
|
636
657
|
|
|
658
|
+
/** `status.accepted.credentialRefused`, validated — only a literal `true` counts. */
|
|
659
|
+
function readAcceptedRefused(raw: unknown): { pending: number } | null {
|
|
660
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
661
|
+
const r = raw as { credentialRefused?: unknown; pending?: unknown };
|
|
662
|
+
if (r.credentialRefused !== true) return null;
|
|
663
|
+
const p = r.pending;
|
|
664
|
+
return { pending: typeof p === 'number' && Number.isInteger(p) && p > 0 && p <= 1e6 ? p : 0 };
|
|
665
|
+
}
|
|
666
|
+
|
|
637
667
|
/** `status.aiAction`, validated — never trusted off disk. */
|
|
638
668
|
function readAiAction(raw: unknown): { state: 'open' | 'held'; canvases: number } | null {
|
|
639
669
|
if (!raw || typeof raw !== 'object') return null;
|
|
@@ -103,6 +103,8 @@ export interface AcceptedSaveStatus {
|
|
|
103
103
|
oldestPendingAt: number | null;
|
|
104
104
|
ackMs: { last: number | null; p95: number | null; n: number };
|
|
105
105
|
rejected: number;
|
|
106
|
+
/** The project refuses this sign-in — changes wait for signing in again. */
|
|
107
|
+
credentialRefused?: boolean;
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
export interface SyncNotice {
|
|
@@ -406,7 +408,9 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
|
|
|
406
408
|
accepted = next;
|
|
407
409
|
// Going from waiting to caught-up (or back) is news; a latency tick is not.
|
|
408
410
|
flush(
|
|
409
|
-
((was?.pending ?? 0) === 0) !== (next.pending === 0) ||
|
|
411
|
+
((was?.pending ?? 0) === 0) !== (next.pending === 0) ||
|
|
412
|
+
next.rejected !== was?.rejected ||
|
|
413
|
+
!!next.credentialRefused !== !!was?.credentialRefused
|
|
410
414
|
);
|
|
411
415
|
},
|
|
412
416
|
updateFiles(next) {
|
|
@@ -98,6 +98,14 @@ export interface TransactionStats {
|
|
|
98
98
|
ackMs: { last: number | null; p95: number | null; n: number };
|
|
99
99
|
/** Final rejections this session (conflicts, invalid source, rights…). */
|
|
100
100
|
rejected: number;
|
|
101
|
+
/**
|
|
102
|
+
* The project answered the last attempt "sign in first": this sign-in was
|
|
103
|
+
* revoked or its person disabled. The change stays in the outbox and is
|
|
104
|
+
* retried with whatever credential `token()` returns next — it is not lost,
|
|
105
|
+
* but it cannot be shared until someone signs in again, which is what a
|
|
106
|
+
* person must be told instead of "check your connection".
|
|
107
|
+
*/
|
|
108
|
+
credentialRefused: boolean;
|
|
101
109
|
}
|
|
102
110
|
|
|
103
111
|
const sha256 = (s: string) => createHash('sha256').update(s, 'utf8').digest('hex');
|
|
@@ -139,6 +147,12 @@ export function createTransactionClient(opts: TransactionClientOptions) {
|
|
|
139
147
|
const waiting = new Map<string, number>();
|
|
140
148
|
const acks: number[] = [];
|
|
141
149
|
let rejectedCount = 0;
|
|
150
|
+
let credentialRefused = false;
|
|
151
|
+
const setCredentialRefused = (next: boolean) => {
|
|
152
|
+
if (credentialRefused === next) return;
|
|
153
|
+
credentialRefused = next;
|
|
154
|
+
opts.onStats?.(stats());
|
|
155
|
+
};
|
|
142
156
|
function stats(): TransactionStats {
|
|
143
157
|
const sorted = [...acks].sort((a, b) => a - b);
|
|
144
158
|
return {
|
|
@@ -152,6 +166,7 @@ export function createTransactionClient(opts: TransactionClientOptions) {
|
|
|
152
166
|
n: acks.length,
|
|
153
167
|
},
|
|
154
168
|
rejected: rejectedCount,
|
|
169
|
+
credentialRefused,
|
|
155
170
|
};
|
|
156
171
|
}
|
|
157
172
|
const setPending = (delta: number) => {
|
|
@@ -239,13 +254,24 @@ export function createTransactionClient(opts: TransactionClientOptions) {
|
|
|
239
254
|
if (attempt > 0) {
|
|
240
255
|
const known = await request('GET', `transactions/${entry.transactionId}`);
|
|
241
256
|
if (known.status === 200 && known.json) {
|
|
257
|
+
setCredentialRefused(false);
|
|
242
258
|
rmSync(file, { force: true });
|
|
243
259
|
return known.json as ProposalResult;
|
|
244
260
|
}
|
|
245
261
|
}
|
|
246
262
|
const { status, json } = await request('POST', 'proposals', entry.bytes);
|
|
263
|
+
// Not an answer about the change — about who is asking. Kept and
|
|
264
|
+
// retried (a new sign-in supplies a new token), and said out loud.
|
|
265
|
+
if (status === 401) {
|
|
266
|
+
setCredentialRefused(true);
|
|
267
|
+
throw new TransactionError(
|
|
268
|
+
'the project no longer accepts this sign-in',
|
|
269
|
+
'unauthenticated'
|
|
270
|
+
);
|
|
271
|
+
}
|
|
247
272
|
const result = json as ProposalResult | null;
|
|
248
273
|
if (result && (result.status === 'accepted' || result.status === 'rejected')) {
|
|
274
|
+
setCredentialRefused(false);
|
|
249
275
|
if (result.status === 'rejected' && (result.code === 'retryable' || status >= 500)) {
|
|
250
276
|
throw new TransactionError('hub asked to retry', 'retryable');
|
|
251
277
|
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
useContext,
|
|
19
19
|
useEffect,
|
|
20
20
|
useMemo,
|
|
21
|
+
useRef,
|
|
21
22
|
useState,
|
|
22
23
|
} from 'react';
|
|
23
24
|
|
|
@@ -53,6 +54,9 @@ export type ShapeKind = 'square' | 'rounded' | 'circle' | 'diamond' | 'triangle'
|
|
|
53
54
|
*/
|
|
54
55
|
export type CanvasMode = 'preview' | 'edit';
|
|
55
56
|
|
|
57
|
+
/** Document event two provider instances keep one active tool through. */
|
|
58
|
+
export const TOOL_SYNC_EVENT = 'maude:tool-sync';
|
|
59
|
+
|
|
56
60
|
/** The resting tool each mode arms (and returns to via `resetTool`). */
|
|
57
61
|
export const MODE_DEFAULT_TOOL: Readonly<Record<CanvasMode, Tool>> = Object.freeze({
|
|
58
62
|
preview: 'browse',
|
|
@@ -231,6 +235,46 @@ export function ToolProvider({
|
|
|
231
235
|
// FigJam look); sharp squares stay one popover click away.
|
|
232
236
|
const [shapeKind, setShapeKind] = useState<ShapeKind>('rounded');
|
|
233
237
|
|
|
238
|
+
// ONE ACTIVE TOOL PER CANVAS DOCUMENT, across provider instances.
|
|
239
|
+
//
|
|
240
|
+
// A UI canvas carries TWO providers: the comment-mount layer's (it owns the
|
|
241
|
+
// comment drop) and canvas-lib's own (it owns the palette) — separate
|
|
242
|
+
// bundles, so separate contexts. They used to converge only through a tool
|
|
243
|
+
// KEYDOWN, which both routers see; a palette CLICK reached canvas-lib's
|
|
244
|
+
// provider alone. So "Comment" in the palette lit up while the comment
|
|
245
|
+
// layer stayed in browse, and clicking the canvas dropped nothing — you had
|
|
246
|
+
// to know to press C (plan T31/L11). A change here is announced on the
|
|
247
|
+
// document and every other instance adopts it. The boot posture is NOT
|
|
248
|
+
// announced (the two instances deliberately boot differently — see
|
|
249
|
+
// `initial`); only a change after mount is.
|
|
250
|
+
const syncId = useRef(Math.random().toString(36).slice(2));
|
|
251
|
+
const toolRef = useRef(tool);
|
|
252
|
+
toolRef.current = tool;
|
|
253
|
+
const announced = useRef(false);
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
if (!announced.current) {
|
|
256
|
+
announced.current = true;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (typeof document === 'undefined') return;
|
|
260
|
+
document.dispatchEvent(
|
|
261
|
+
new CustomEvent(TOOL_SYNC_EVENT, { detail: { tool, from: syncId.current } })
|
|
262
|
+
);
|
|
263
|
+
}, [tool]);
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
if (typeof document === 'undefined') return;
|
|
266
|
+
const onSync = (e: Event) => {
|
|
267
|
+
const d = (e as CustomEvent<{ tool?: unknown; from?: unknown }>).detail;
|
|
268
|
+
if (!d || d.from === syncId.current || typeof d.tool !== 'string') return;
|
|
269
|
+
// Already there: nothing to do, and — for a sticky-locked tool — not
|
|
270
|
+
// touching it keeps the lock.
|
|
271
|
+
if (d.tool === toolRef.current) return;
|
|
272
|
+
setTool(d.tool as Tool);
|
|
273
|
+
};
|
|
274
|
+
document.addEventListener(TOOL_SYNC_EVENT, onSync);
|
|
275
|
+
return () => document.removeEventListener(TOOL_SYNC_EVENT, onSync);
|
|
276
|
+
}, [setTool]);
|
|
277
|
+
|
|
234
278
|
// Cursor sync — applied inside the canvas (this hook runs in the canvas
|
|
235
279
|
// context). The active tool's cursor is set on <body> AND forced across the
|
|
236
280
|
// whole canvas working area via an `!important` rule, so the custom cursor
|
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "live-canvases-long-sessions",
|
|
6
|
+
"version": "1.3.1",
|
|
7
|
+
"date": "2026-09-15",
|
|
8
|
+
"kind": "fix",
|
|
9
|
+
"title": "Canvases stay live in long sessions",
|
|
10
|
+
"summary": "A canvas open in your browser on a Maude Cloud project kept working for about 15 minutes and then quietly stopped showing teammates’ changes until you reloaded the page — it now stays live however long you work. And editing a design system’s tokens.css restyles every open canvas that uses it, straight away.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "team-access-and-presence",
|
|
15
|
+
"version": "1.3.1",
|
|
16
|
+
"date": "2026-09-15",
|
|
17
|
+
"kind": "fix",
|
|
18
|
+
"title": "Lost access? Maude asks you to sign in again",
|
|
19
|
+
"summary": "If an owner removes your access or your sign-in expires, the Sync panel now tells you to sign in again — instead of \"check your connection\" — and keeps your changes on this computer until you do. Signing in again sends them. And when a teammate closes a canvas, their avatar and cursor now leave everyone else’s screen straight away.",
|
|
20
|
+
"surface": "design-ui"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "comment-tool-and-live-tree",
|
|
24
|
+
"version": "1.3.1",
|
|
25
|
+
"date": "2026-09-15",
|
|
26
|
+
"kind": "fix",
|
|
27
|
+
"title": "The Comment tool works from the toolbar; new images and folders show up for everyone at once",
|
|
28
|
+
"summary": "Picking Comment in the canvas toolbar now lets you click an element to comment — before, only the C key did. An image saved next to your canvases appears in the file tree straight away, on your machine and your teammates’. A folder you create or move outside Maude, or move in the tree, reaches the team in seconds instead of on the next launch.",
|
|
29
|
+
"surface": "design-ui"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "inspector-keeps-number-types",
|
|
33
|
+
"version": "1.3.1",
|
|
34
|
+
"date": "2026-09-15",
|
|
35
|
+
"kind": "fix",
|
|
36
|
+
"title": "Editing a number in the inspector keeps it a number",
|
|
37
|
+
"summary": "Changing a value like an artboard height or a video’s length used to turn it into text in the canvas source — some components then misread it. Numbers and on/off values now stay what they were, and leaving a value unchanged no longer rewrites the file.",
|
|
38
|
+
"surface": "design-ui"
|
|
39
|
+
},
|
|
4
40
|
{
|
|
5
41
|
"id": "artboard-rename-and-files",
|
|
6
42
|
"version": "1.3.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1agh/maude",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -56,13 +56,13 @@
|
|
|
56
56
|
"test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
|
|
57
57
|
},
|
|
58
58
|
"optionalDependencies": {
|
|
59
|
-
"@1agh/maude-darwin-arm64": "1.3.
|
|
60
|
-
"@1agh/maude-darwin-x64": "1.3.
|
|
61
|
-
"@1agh/maude-linux-arm64": "1.3.
|
|
62
|
-
"@1agh/maude-linux-arm64-musl": "1.3.
|
|
63
|
-
"@1agh/maude-linux-x64": "1.3.
|
|
64
|
-
"@1agh/maude-linux-x64-musl": "1.3.
|
|
65
|
-
"@1agh/maude-win32-x64": "1.3.
|
|
59
|
+
"@1agh/maude-darwin-arm64": "1.3.1",
|
|
60
|
+
"@1agh/maude-darwin-x64": "1.3.1",
|
|
61
|
+
"@1agh/maude-linux-arm64": "1.3.1",
|
|
62
|
+
"@1agh/maude-linux-arm64-musl": "1.3.1",
|
|
63
|
+
"@1agh/maude-linux-x64": "1.3.1",
|
|
64
|
+
"@1agh/maude-linux-x64-musl": "1.3.1",
|
|
65
|
+
"@1agh/maude-win32-x64": "1.3.1"
|
|
66
66
|
},
|
|
67
67
|
"files": [
|
|
68
68
|
"cli",
|