@1agh/maude 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/annotation-edit-base.ts +36 -0
- package/apps/studio/annotations-layer.tsx +13 -2
- package/apps/studio/api.ts +51 -2
- package/apps/studio/canvas-build.ts +49 -8
- package/apps/studio/canvas-edit.ts +77 -0
- package/apps/studio/canvas-lib.tsx +25 -10
- package/apps/studio/canvas-list-watch.ts +33 -2
- package/apps/studio/client/app.jsx +99 -4
- package/apps/studio/client/canvas-url.js +39 -1
- package/apps/studio/client/panels/TimelinePanel.jsx +75 -62
- package/apps/studio/client/photo-knobs.jsx +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +1 -0
- package/apps/studio/collab/awareness-bridge.ts +65 -14
- package/apps/studio/comments-overlay.tsx +86 -2
- package/apps/studio/context.ts +93 -1
- package/apps/studio/dist/client.bundle.js +619 -619
- 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 +15 -1
- package/apps/studio/managed-projects.ts +22 -0
- package/apps/studio/server.ts +3 -0
- package/apps/studio/sync/accepted-link.ts +3 -0
- package/apps/studio/sync/file-plane.ts +15 -0
- package/apps/studio/sync/index.ts +138 -12
- package/apps/studio/sync/presentation.ts +30 -0
- package/apps/studio/sync/status.ts +5 -1
- package/apps/studio/sync/transaction-client.ts +29 -0
- package/apps/studio/use-tool-mode.tsx +44 -0
- package/apps/studio/whats-new.json +72 -0
- package/apps/studio/ws.ts +9 -2
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +48 -7
- package/plugins/flow/.claude-plugin/config.schema.json +15 -2
- package/plugins/flow/README.md +1 -1
|
@@ -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
|
},
|
|
@@ -5518,9 +5529,12 @@ export function createHttp(
|
|
|
5518
5529
|
if (!body || typeof body.body !== 'string' || !body.body.trim()) {
|
|
5519
5530
|
return new Response('body.body required', { status: 400 });
|
|
5520
5531
|
}
|
|
5532
|
+
// In a cell the proxy vouches who is replying; a body cannot claim
|
|
5533
|
+
// somebody else (and the cell's git identity is the machine's).
|
|
5534
|
+
const vouched = isWorkspaceMode() ? req.headers.get('x-maude-user') : null;
|
|
5521
5535
|
const next = await api.commentsAddReply(id, {
|
|
5522
5536
|
body: body.body,
|
|
5523
|
-
author: typeof body.author === 'string' ? body.author : undefined,
|
|
5537
|
+
author: vouched || (typeof body.author === 'string' ? body.author : undefined),
|
|
5524
5538
|
});
|
|
5525
5539
|
if (!next) return new Response('Not found', { status: 404 });
|
|
5526
5540
|
return Response.json(next, { headers: { 'Cache-Control': 'no-store' } });
|
|
@@ -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, '-');
|
package/apps/studio/server.ts
CHANGED
|
@@ -468,6 +468,9 @@ function startServer(port: number): BunServer {
|
|
|
468
468
|
// D3 — whose socket this is, from the proxy's vouched header. Same
|
|
469
469
|
// handshake-time reasoning as `readOnly` below; `''` on a desktop.
|
|
470
470
|
session: WORKSPACE ? normalizeSessionKey(req.headers.get(SESSION_HEADER)) : '',
|
|
471
|
+
// Who, for attribution (a comment's author) — the proxy's vouched
|
|
472
|
+
// member; a desktop leaves it to the project's git identity.
|
|
473
|
+
user: WORKSPACE ? (req.headers.get('x-maude-user') ?? '').slice(0, 120) : '',
|
|
471
474
|
// Cloud Phase 27 — stamp the role onto the socket at the handshake,
|
|
472
475
|
// the one moment the session is unambiguous. Fails CLOSED in a cell
|
|
473
476
|
// for the same reason the HTTP gate does: an absent header is an
|
|
@@ -41,6 +41,8 @@ export interface AcceptedLinkOptions {
|
|
|
41
41
|
client?: TransactionClient;
|
|
42
42
|
/** T16 — an AI action opened, was held, or ended. */
|
|
43
43
|
onStage?: (summary: StageSummary | null) => void;
|
|
44
|
+
/** Every bootstrap the project answered (its manifest and its own config). */
|
|
45
|
+
onBootstrap?: (b: Bootstrap) => void;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export type StructuralOutcome = ProposalOutcome & { queued?: boolean };
|
|
@@ -96,6 +98,7 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
|
|
|
96
98
|
}
|
|
97
99
|
mode = next;
|
|
98
100
|
manifest = b;
|
|
101
|
+
opts.onBootstrap?.(b);
|
|
99
102
|
return b;
|
|
100
103
|
} catch (err) {
|
|
101
104
|
if (err instanceof TransactionError && err.code === 'absent') mode = 'legacy';
|
|
@@ -1600,6 +1600,21 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
1600
1600
|
holdOversized(out, ledger, here);
|
|
1601
1601
|
continue;
|
|
1602
1602
|
}
|
|
1603
|
+
// THE FILE THAT WAS TOO BIG IS GONE. It never reached the project and
|
|
1604
|
+
// is no longer on this machine, so nothing is held any more — the panel
|
|
1605
|
+
// kept saying "1 file — too big" for a file that no longer existed.
|
|
1606
|
+
const kept = ledger.row(rel);
|
|
1607
|
+
if (
|
|
1608
|
+
!here &&
|
|
1609
|
+
kept?.state === 'refused' &&
|
|
1610
|
+
kept.blockedClass === 'too-large' &&
|
|
1611
|
+
!kept.syncedHash &&
|
|
1612
|
+
!(row && !row.deleted) &&
|
|
1613
|
+
!ledger.remoteOf(rel)
|
|
1614
|
+
) {
|
|
1615
|
+
ledger.forget(rel);
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
1603
1618
|
// What the hub holds: this page when it spoke about the path, otherwise
|
|
1604
1619
|
// what we last learned. `undefined` (never learned) reads as null only
|
|
1605
1620
|
// after a full read has had the chance to say so.
|
|
@@ -36,6 +36,7 @@ import * as Y from 'yjs';
|
|
|
36
36
|
import { renewHubCredential } from '../cloud/renew.ts';
|
|
37
37
|
import { Y_TYPES } from '../collab/persistence.ts';
|
|
38
38
|
import type { Context, LinkedHub } from '../context.ts';
|
|
39
|
+
import { PROJECT_CONFIG_CACHE_REL, reloadConfig, sanitizeProjectConfig } from '../context.ts';
|
|
39
40
|
import { createHistory } from '../history.ts';
|
|
40
41
|
import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
|
|
41
42
|
import { acceptedColdStart } from './accepted-cold-start.ts';
|
|
@@ -924,8 +925,39 @@ export function createSyncRuntime(
|
|
|
924
925
|
retryMs: opts.transactionRetryMs,
|
|
925
926
|
onStats: (stats) => statusStore?.updateAccepted?.(stats),
|
|
926
927
|
onStage: (summary) => statusStore?.updateAiAction?.(summary),
|
|
928
|
+
onBootstrap: (b) => noteProjectConfig(b.projectConfig),
|
|
927
929
|
})
|
|
928
930
|
: null;
|
|
931
|
+
/**
|
|
932
|
+
* The project's own group labels and design systems, kept for this copy's
|
|
933
|
+
* config to fill in what it lacks (context.ts `withProjectConfig`) — so a
|
|
934
|
+
* managed desktop copy shows the project's design system. Written only when
|
|
935
|
+
* it changed; the live config reloads and the shells refetch it.
|
|
936
|
+
*/
|
|
937
|
+
function noteProjectConfig(raw: unknown): void {
|
|
938
|
+
const clean = sanitizeProjectConfig(raw);
|
|
939
|
+
if (!clean) return;
|
|
940
|
+
const file = path.join(ctx.paths.designRoot, PROJECT_CONFIG_CACHE_REL);
|
|
941
|
+
const text = `${JSON.stringify(clean, null, 2)}\n`;
|
|
942
|
+
try {
|
|
943
|
+
if (readFileSync(file, 'utf8') === text) return;
|
|
944
|
+
} catch {
|
|
945
|
+
/* first answer */
|
|
946
|
+
}
|
|
947
|
+
try {
|
|
948
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
949
|
+
writeFileSync(file, text);
|
|
950
|
+
} catch {
|
|
951
|
+
return; // best-effort — the next bootstrap tries again
|
|
952
|
+
}
|
|
953
|
+
if (reloadConfig(ctx)) {
|
|
954
|
+
console.log(
|
|
955
|
+
`[sync] the project's design systems: ${clean.designSystems.map((d) => d.name).join(', ') || 'none'}`
|
|
956
|
+
);
|
|
957
|
+
ctx.bus.emit('config-updated');
|
|
958
|
+
ctx.bus.emit('canvas-list-update', { action: 'config' });
|
|
959
|
+
}
|
|
960
|
+
}
|
|
929
961
|
const acceptedOn = (): boolean => acceptedLink?.on() === true;
|
|
930
962
|
/**
|
|
931
963
|
* T26 — which accepted action carried a given canvas content of ours, so the
|
|
@@ -1222,6 +1254,9 @@ export function createSyncRuntime(
|
|
|
1222
1254
|
/** The outbound delete-lane subscriptions — see `noteToHub`. */
|
|
1223
1255
|
let deletedUnsub: (() => void) | null = null;
|
|
1224
1256
|
let createdUnsub: (() => void) | null = null;
|
|
1257
|
+
/** Folders made outside the app while running — see `proposeLocalFolders`. */
|
|
1258
|
+
let foldersUnsub: (() => void) | null = null;
|
|
1259
|
+
let foldersTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1225
1260
|
/** Periodic remote-document poll — the hub-side half of discovery. */
|
|
1226
1261
|
let remotePollTimer: ReturnType<typeof setInterval> | null = null;
|
|
1227
1262
|
/** The stall watchdog's tick — see `stallCheck` in `start()`. */
|
|
@@ -1312,6 +1347,15 @@ export function createSyncRuntime(
|
|
|
1312
1347
|
* work), and passes are floored `MIN_PASS_INTERVAL_MS` apart.
|
|
1313
1348
|
*/
|
|
1314
1349
|
let planePassInFlight = false;
|
|
1350
|
+
/**
|
|
1351
|
+
* News that arrived WHILE a pass ran. The running pass may already have read
|
|
1352
|
+
* the disk and the hub's listing, so it cannot carry a file written — or a
|
|
1353
|
+
* row a poke announced — after that point; absorbing the request silently
|
|
1354
|
+
* parked such a file until the next 20 s tick (plan T17/L03: a new image
|
|
1355
|
+
* took 15–20 s to reach a teammate while an edit took one). One more pass,
|
|
1356
|
+
* floored like any other, answers it.
|
|
1357
|
+
*/
|
|
1358
|
+
let planePassAgain = false;
|
|
1315
1359
|
let lastPlanePassAt = 0;
|
|
1316
1360
|
let planePassWaiter: Promise<void> | null = null;
|
|
1317
1361
|
let lastPlaneResult: import('./file-plane.ts').FilePlaneResult | null = null;
|
|
@@ -1323,7 +1367,10 @@ export function createSyncRuntime(
|
|
|
1323
1367
|
// A pass already running IS this pass: it reads the same disk and the same
|
|
1324
1368
|
// cursor, so the caller waits for its answer rather than racing it. The
|
|
1325
1369
|
// poll's `await` therefore still means "a pass has happened".
|
|
1326
|
-
if (planePassInFlight)
|
|
1370
|
+
if (planePassInFlight) {
|
|
1371
|
+
planePassAgain = true;
|
|
1372
|
+
return await (planePassWaiter ?? Promise.resolve());
|
|
1373
|
+
}
|
|
1327
1374
|
const since = Date.now() - lastPlanePassAt;
|
|
1328
1375
|
// The floor governs POKES. The 20 s poll and the explicit `pullRemoteNow`
|
|
1329
1376
|
// seam are already bounded by their own callers, and silently deferring a
|
|
@@ -1346,6 +1393,10 @@ export function createSyncRuntime(
|
|
|
1346
1393
|
} finally {
|
|
1347
1394
|
planePassInFlight = false;
|
|
1348
1395
|
planePassWaiter = null;
|
|
1396
|
+
if (planePassAgain) {
|
|
1397
|
+
planePassAgain = false;
|
|
1398
|
+
schedulePlanePass();
|
|
1399
|
+
}
|
|
1349
1400
|
}
|
|
1350
1401
|
})();
|
|
1351
1402
|
planePassWaiter = run;
|
|
@@ -1867,9 +1918,33 @@ export function createSyncRuntime(
|
|
|
1867
1918
|
return abs;
|
|
1868
1919
|
};
|
|
1869
1920
|
const designRelPosix = ctx.paths.designRel.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
|
|
1921
|
+
/**
|
|
1922
|
+
* A folder the project removed or moved away is taken off this disk only
|
|
1923
|
+
* once it is empty — but the canvases inside leave AFTER the pull that
|
|
1924
|
+
* learned of the action (their moves and deletions land through their own
|
|
1925
|
+
* documents a beat later). Waiting for the next poke or 20 s tick left the
|
|
1926
|
+
* old folder in a teammate's tree for up to 20 s (plan T31/L02). So a
|
|
1927
|
+
* deferred removal asks for another pass shortly, a bounded number of times.
|
|
1928
|
+
*/
|
|
1929
|
+
let dirsRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1930
|
+
let dirsRetries = 0;
|
|
1931
|
+
const DIRS_RETRY_MS = 1_500;
|
|
1932
|
+
const DIRS_RETRY_MAX = 12;
|
|
1933
|
+
function retryDeferredDirs(): void {
|
|
1934
|
+
if (stopped || dirsRetryTimer !== null || dirsRetries >= DIRS_RETRY_MAX) return;
|
|
1935
|
+
dirsRetries += 1;
|
|
1936
|
+
dirsRetryTimer = setTimeout(() => {
|
|
1937
|
+
dirsRetryTimer = null;
|
|
1938
|
+
if (stopped || !acceptedOn()) return;
|
|
1939
|
+
if (documentDiscovery) documentDiscovery.schedule();
|
|
1940
|
+
else applyProjectDirs(acceptedLink?.manifest?.dirs ?? []);
|
|
1941
|
+
}, DIRS_RETRY_MS);
|
|
1942
|
+
dirsRetryTimer.unref?.();
|
|
1943
|
+
}
|
|
1870
1944
|
function applyProjectDirs(dirs: readonly string[]): void {
|
|
1871
1945
|
const next = new Set(dirs);
|
|
1872
1946
|
let changed = false;
|
|
1947
|
+
let deferred = 0;
|
|
1873
1948
|
for (const rel of next) {
|
|
1874
1949
|
if (knownProjectDirs.has(rel)) continue;
|
|
1875
1950
|
const abs = projectDirAbs(rel);
|
|
@@ -1903,8 +1978,11 @@ export function createSyncRuntime(
|
|
|
1903
1978
|
continue;
|
|
1904
1979
|
}
|
|
1905
1980
|
// Canvases inside leave through their own deletion/move first; until
|
|
1906
|
-
// then the folder stays and
|
|
1907
|
-
if (entries.some((e) => e !== '.gitkeep'))
|
|
1981
|
+
// then the folder stays and a pass shortly tries again.
|
|
1982
|
+
if (entries.some((e) => e !== '.gitkeep')) {
|
|
1983
|
+
deferred += 1;
|
|
1984
|
+
continue;
|
|
1985
|
+
}
|
|
1908
1986
|
try {
|
|
1909
1987
|
for (const e of readdirSync(abs)) rmSync(path.join(abs, e), { force: true });
|
|
1910
1988
|
rmdirSync(abs);
|
|
@@ -1920,6 +1998,8 @@ export function createSyncRuntime(
|
|
|
1920
1998
|
changed = true;
|
|
1921
1999
|
}
|
|
1922
2000
|
if (changed) saveProjectDirs();
|
|
2001
|
+
if (deferred > 0) retryDeferredDirs();
|
|
2002
|
+
else dirsRetries = 0;
|
|
1923
2003
|
}
|
|
1924
2004
|
|
|
1925
2005
|
/**
|
|
@@ -1975,9 +2055,17 @@ export function createSyncRuntime(
|
|
|
1975
2055
|
* project as ONE action at cold start. Only additive: a folder the manifest
|
|
1976
2056
|
* lists and this disk lacks is never proposed as a deletion from here.
|
|
1977
2057
|
*/
|
|
2058
|
+
let folderProposal: Promise<void> | null = null;
|
|
2059
|
+
let folderProposalAgain = false;
|
|
1978
2060
|
function proposeLocalFolders(): void {
|
|
1979
2061
|
if (!acceptedLink) return;
|
|
1980
|
-
|
|
2062
|
+
// One proposal at a time: a second walk while the first is unanswered
|
|
2063
|
+
// would offer the same folders twice.
|
|
2064
|
+
if (folderProposal) {
|
|
2065
|
+
folderProposalAgain = true;
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
const listed = new Set([...(acceptedLink.manifest?.dirs ?? []), ...knownProjectDirs]);
|
|
1981
2069
|
const found: string[] = [];
|
|
1982
2070
|
const walk = (abs: string, rel: string, depth: number) => {
|
|
1983
2071
|
if (depth > 16 || found.length >= 200) return;
|
|
@@ -2006,13 +2094,25 @@ export function createSyncRuntime(
|
|
|
2006
2094
|
if (rel) walk(path.join(ctx.paths.designRoot, ...rel.split('/')), rel, 1);
|
|
2007
2095
|
}
|
|
2008
2096
|
if (found.length === 0) return;
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2097
|
+
folderProposal = acceptedLink
|
|
2098
|
+
.dirsCreate(found)
|
|
2099
|
+
.then((r) => {
|
|
2100
|
+
if (r.status === 'accepted') {
|
|
2101
|
+
for (const d of found) knownProjectDirs.add(d);
|
|
2102
|
+
saveProjectDirs();
|
|
2103
|
+
console.log(`[sync] added ${found.length} local folder(s) to the project.`);
|
|
2104
|
+
}
|
|
2105
|
+
})
|
|
2106
|
+
.catch(() => {
|
|
2107
|
+
/* the next folder event or cold start offers them again */
|
|
2108
|
+
})
|
|
2109
|
+
.finally(() => {
|
|
2110
|
+
folderProposal = null;
|
|
2111
|
+
if (folderProposalAgain) {
|
|
2112
|
+
folderProposalAgain = false;
|
|
2113
|
+
proposeLocalFolders();
|
|
2114
|
+
}
|
|
2115
|
+
});
|
|
2016
2116
|
}
|
|
2017
2117
|
|
|
2018
2118
|
function proposeFolder(
|
|
@@ -3535,7 +3635,15 @@ export function createSyncRuntime(
|
|
|
3535
3635
|
// the HMR broadcaster's per-file coalescing collapses the pair
|
|
3536
3636
|
// into one `canvas-hmr`. The projector's own echo guard drops the
|
|
3537
3637
|
// resulting file→doc read, so this cannot loop.
|
|
3538
|
-
|
|
3638
|
+
onWrote: (abs: string) => {
|
|
3639
|
+
// Tell the canvas layer this write carries the PROJECT's
|
|
3640
|
+
// version — possibly a teammate's change merged under this
|
|
3641
|
+
// person's own — so an open canvas never discards its reload
|
|
3642
|
+
// as the echo of an optimistic edit (hmr-broadcast `remote`).
|
|
3643
|
+
const rel = path.relative(ctx.paths.designRoot, abs).split(path.sep).join('/');
|
|
3644
|
+
if (rel && !rel.startsWith('..')) ctx.bus.emit('sync:projected', rel);
|
|
3645
|
+
if (cellPairing) announceWrite(abs);
|
|
3646
|
+
},
|
|
3539
3647
|
});
|
|
3540
3648
|
projection.start();
|
|
3541
3649
|
projections.set(canvas.slug, projection);
|
|
@@ -3818,6 +3926,18 @@ export function createSyncRuntime(
|
|
|
3818
3926
|
});
|
|
3819
3927
|
discoveryRescan = rescan;
|
|
3820
3928
|
discoveryUnsub = ctx.bus.on('canvas-list-update', () => rescan.schedule());
|
|
3929
|
+
// A folder made OUTSIDE the app while it runs — an agent's `mkdir` plus
|
|
3930
|
+
// `.gitkeep`, Finder, a `git checkout` — joins the project the way a cold
|
|
3931
|
+
// start adds one: additively, never as a deletion. Without this it stayed
|
|
3932
|
+
// on this machine until the next launch.
|
|
3933
|
+
foldersUnsub = ctx.bus.on('fs:any', (rel: unknown) => {
|
|
3934
|
+
if (typeof rel !== 'string' || !rel.endsWith('/.gitkeep') || !acceptedOn()) return;
|
|
3935
|
+
if (foldersTimer) clearTimeout(foldersTimer);
|
|
3936
|
+
foldersTimer = setTimeout(() => {
|
|
3937
|
+
foldersTimer = null;
|
|
3938
|
+
if (acceptedOn()) proposeLocalFolders();
|
|
3939
|
+
}, 300);
|
|
3940
|
+
});
|
|
3821
3941
|
|
|
3822
3942
|
// The OUTBOUND half of the delete lane. `api.ts` emits these two only from
|
|
3823
3943
|
// its privileged create/delete routes, never from the filesystem watcher —
|
|
@@ -4518,6 +4638,12 @@ export function createSyncRuntime(
|
|
|
4518
4638
|
attachOne = null;
|
|
4519
4639
|
discoveryUnsub?.();
|
|
4520
4640
|
discoveryUnsub = null;
|
|
4641
|
+
foldersUnsub?.();
|
|
4642
|
+
foldersUnsub = null;
|
|
4643
|
+
if (foldersTimer) clearTimeout(foldersTimer);
|
|
4644
|
+
foldersTimer = null;
|
|
4645
|
+
if (dirsRetryTimer) clearTimeout(dirsRetryTimer);
|
|
4646
|
+
dirsRetryTimer = null;
|
|
4521
4647
|
deletedUnsub?.();
|
|
4522
4648
|
deletedUnsub = null;
|
|
4523
4649
|
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) {
|
|
@@ -51,6 +51,9 @@ export interface Bootstrap {
|
|
|
51
51
|
}[];
|
|
52
52
|
dirs: string[];
|
|
53
53
|
you?: { actor: string; readOnly: boolean };
|
|
54
|
+
/** What the project's own config says about itself (UNTRUSTED — a copy
|
|
55
|
+
* re-validates it; see context.ts `withProjectConfig`). */
|
|
56
|
+
projectConfig?: unknown;
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
interface OutboxAction {
|
|
@@ -98,6 +101,14 @@ export interface TransactionStats {
|
|
|
98
101
|
ackMs: { last: number | null; p95: number | null; n: number };
|
|
99
102
|
/** Final rejections this session (conflicts, invalid source, rights…). */
|
|
100
103
|
rejected: number;
|
|
104
|
+
/**
|
|
105
|
+
* The project answered the last attempt "sign in first": this sign-in was
|
|
106
|
+
* revoked or its person disabled. The change stays in the outbox and is
|
|
107
|
+
* retried with whatever credential `token()` returns next — it is not lost,
|
|
108
|
+
* but it cannot be shared until someone signs in again, which is what a
|
|
109
|
+
* person must be told instead of "check your connection".
|
|
110
|
+
*/
|
|
111
|
+
credentialRefused: boolean;
|
|
101
112
|
}
|
|
102
113
|
|
|
103
114
|
const sha256 = (s: string) => createHash('sha256').update(s, 'utf8').digest('hex');
|
|
@@ -139,6 +150,12 @@ export function createTransactionClient(opts: TransactionClientOptions) {
|
|
|
139
150
|
const waiting = new Map<string, number>();
|
|
140
151
|
const acks: number[] = [];
|
|
141
152
|
let rejectedCount = 0;
|
|
153
|
+
let credentialRefused = false;
|
|
154
|
+
const setCredentialRefused = (next: boolean) => {
|
|
155
|
+
if (credentialRefused === next) return;
|
|
156
|
+
credentialRefused = next;
|
|
157
|
+
opts.onStats?.(stats());
|
|
158
|
+
};
|
|
142
159
|
function stats(): TransactionStats {
|
|
143
160
|
const sorted = [...acks].sort((a, b) => a - b);
|
|
144
161
|
return {
|
|
@@ -152,6 +169,7 @@ export function createTransactionClient(opts: TransactionClientOptions) {
|
|
|
152
169
|
n: acks.length,
|
|
153
170
|
},
|
|
154
171
|
rejected: rejectedCount,
|
|
172
|
+
credentialRefused,
|
|
155
173
|
};
|
|
156
174
|
}
|
|
157
175
|
const setPending = (delta: number) => {
|
|
@@ -239,13 +257,24 @@ export function createTransactionClient(opts: TransactionClientOptions) {
|
|
|
239
257
|
if (attempt > 0) {
|
|
240
258
|
const known = await request('GET', `transactions/${entry.transactionId}`);
|
|
241
259
|
if (known.status === 200 && known.json) {
|
|
260
|
+
setCredentialRefused(false);
|
|
242
261
|
rmSync(file, { force: true });
|
|
243
262
|
return known.json as ProposalResult;
|
|
244
263
|
}
|
|
245
264
|
}
|
|
246
265
|
const { status, json } = await request('POST', 'proposals', entry.bytes);
|
|
266
|
+
// Not an answer about the change — about who is asking. Kept and
|
|
267
|
+
// retried (a new sign-in supplies a new token), and said out loud.
|
|
268
|
+
if (status === 401) {
|
|
269
|
+
setCredentialRefused(true);
|
|
270
|
+
throw new TransactionError(
|
|
271
|
+
'the project no longer accepts this sign-in',
|
|
272
|
+
'unauthenticated'
|
|
273
|
+
);
|
|
274
|
+
}
|
|
247
275
|
const result = json as ProposalResult | null;
|
|
248
276
|
if (result && (result.status === 'accepted' || result.status === 'rejected')) {
|
|
277
|
+
setCredentialRefused(false);
|
|
249
278
|
if (result.status === 'rejected' && (result.code === 'retryable' || status >= 500)) {
|
|
250
279
|
throw new TransactionError('hub asked to retry', 'retryable');
|
|
251
280
|
}
|