@1agh/maude 0.56.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +5 -1
  2. package/apps/studio/acp/bridge.ts +385 -26
  3. package/apps/studio/acp/index.ts +498 -102
  4. package/apps/studio/acp/running.ts +97 -0
  5. package/apps/studio/acp/transcript.ts +64 -0
  6. package/apps/studio/acp/write-scope.ts +459 -0
  7. package/apps/studio/api.ts +69 -4
  8. package/apps/studio/build.ts +28 -1
  9. package/apps/studio/client/app.jsx +187 -39
  10. package/apps/studio/client/panels/ChatPanel.jsx +19 -1
  11. package/apps/studio/client/panels/CloudBar.jsx +72 -13
  12. package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
  13. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
  14. package/apps/studio/client/panels/acp-runtime.js +96 -5
  15. package/apps/studio/client/panels/chat-markdown.jsx +76 -5
  16. package/apps/studio/client/panels/file-preview.jsx +115 -0
  17. package/apps/studio/client/styles/3-shell-maude.css +56 -2
  18. package/apps/studio/client/styles/6-acp-chat.css +79 -0
  19. package/apps/studio/dist/client.bundle.js +1436 -1436
  20. package/apps/studio/dist/comment-mount.js +2 -2
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/http.ts +59 -2
  23. package/apps/studio/input-router.tsx +39 -1
  24. package/apps/studio/server.ts +11 -0
  25. package/apps/studio/sync/connection-state.ts +116 -6
  26. package/apps/studio/sync/index.ts +50 -1
  27. package/apps/studio/sync/presentation.ts +273 -0
  28. package/apps/studio/sync/remote-docs.ts +191 -0
  29. package/apps/studio/sync/status.ts +4 -1
  30. package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
  31. package/apps/studio/test/acp-branch-guard.test.ts +123 -0
  32. package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
  33. package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
  34. package/apps/studio/test/acp-commands.test.ts +5 -0
  35. package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
  36. package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
  37. package/apps/studio/test/acp-permission.test.ts +18 -2
  38. package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
  39. package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
  40. package/apps/studio/test/acp-write-gate.test.ts +323 -0
  41. package/apps/studio/test/acp-write-scope.test.ts +533 -0
  42. package/apps/studio/test/bundle-smoke.test.ts +35 -18
  43. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  44. package/apps/studio/test/cloud-connect-note.test.ts +135 -0
  45. package/apps/studio/test/cloud-endpoints.test.ts +11 -4
  46. package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
  47. package/apps/studio/test/config-version.test.ts +88 -0
  48. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
  49. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
  50. package/apps/studio/test/input-router.test.ts +56 -0
  51. package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
  52. package/apps/studio/test/sync-connection-state.test.ts +45 -1
  53. package/apps/studio/test/sync-presentation.test.ts +185 -0
  54. package/apps/studio/test/sync-remote-docs.test.ts +171 -0
  55. package/apps/studio/test/whats-new.test.ts +13 -1
  56. package/apps/studio/whats-new.json +27 -0
  57. package/apps/studio/whats-new.ts +34 -10
  58. package/package.json +8 -8
@@ -0,0 +1,273 @@
1
+ // One rule for "what is the hub link doing", shared by every surface.
2
+ //
3
+ // There were two answers to that question and they disagreed. The status bar
4
+ // keyed off `state` alone — it referenced `docs` zero times, so a link whose
5
+ // every document the hub had refused still showed a green dot and the word
6
+ // "synced". The connect note in the cloud rail keyed off the ATTACH RESPONSE,
7
+ // a value that means "the runtime started", and never updated again. A user
8
+ // who distrusted one was sent to the other, which was wrong in a different way.
9
+ //
10
+ // So the rule lives here, once, as a pure function over the payload both
11
+ // surfaces already receive. Agreement between them is then structural rather
12
+ // than a thing two files have to remember.
13
+ //
14
+ // DDR-054 — document names and project names originate at the hub. Counts come
15
+ // first in every sentence so a hostile name cannot dominate it, names are
16
+ // capped, and nothing here produces markup.
17
+
18
+ import type { SyncStatusSnapshot } from './connection-state.ts';
19
+
20
+ /** Where a link is, in the terms a person cares about. */
21
+ export type SyncPhase =
22
+ | 'connecting'
23
+ | 'syncing'
24
+ | 'synced'
25
+ | 'refused'
26
+ | 'offline'
27
+ | 'nothing-syncable';
28
+
29
+ export interface SyncPresentation {
30
+ phase: SyncPhase;
31
+ /** Green dot — reserved for a link that is genuinely carrying edits. */
32
+ online: boolean;
33
+ /** Status-bar slot text. Terse; never contains a hub-supplied name. */
34
+ label: string;
35
+ /** Full sentence — the rail note and the status-bar hover. */
36
+ title: string;
37
+ /**
38
+ * What the person should do now, or null when the honest answer is "nothing,
39
+ * it is working". Every non-null phase names one — being told a state without
40
+ * being told the move is the complaint this whole change exists for.
41
+ */
42
+ next: string | null;
43
+ /** Up to 3 hub-supplied names relevant to the phase (refused / pulled). */
44
+ names: string[];
45
+ }
46
+
47
+ /**
48
+ * The `/_sync-status` payload, in its three historical shapes:
49
+ * - `{ linked: false }` — solo project, no link
50
+ * - `{ notSyncable, tsxCount, reason }` — DDR-060, linked but 0 syncable
51
+ * - the connection-state snapshot + url/canvases (the common case)
52
+ *
53
+ * Every field is optional on purpose: pre-DDR-102 payloads have no `docs`, and
54
+ * the browser and the CLI read the same JSON.
55
+ */
56
+ export interface SyncStatusLike extends Partial<SyncStatusSnapshot> {
57
+ linked?: boolean;
58
+ notSyncable?: boolean;
59
+ tsxCount?: number;
60
+ reason?: string;
61
+ canvases?: number;
62
+ }
63
+
64
+ /** Hub-supplied text that reaches a UI. Bounded, never markup. */
65
+ const MAX_NAME_LEN = 60;
66
+ const SHOWN_NAMES = 3;
67
+
68
+ export function safeName(raw: unknown, fallback: string): string {
69
+ // Length alone was not enough. A name is hub-supplied (cell mode sets it from
70
+ // `MAUDE_PROJECT_NAME`; `.design/config.json` is a committed file anyone with
71
+ // repo access can author), and it lands in trusted app chrome — including a
72
+ // `title=` tooltip, where a newline RENDERS and can push the true clause out
73
+ // of view. `All 75 canvases synced.\n\n\n` fits inside 60 characters and
74
+ // reads as a reassuring sentence of ours.
75
+ //
76
+ // So: strip control and format characters (which covers the bidi overrides
77
+ // U+202A–U+202E / U+2066–U+2069 — a name that reverses the rest of the line
78
+ // is the same attack by a different mechanism), then collapse whitespace to
79
+ // single spaces so no name can ever contain a line break.
80
+ const s = String(raw ?? '')
81
+ .replace(/[\p{Cc}\p{Cf}]/gu, '')
82
+ .replace(/\s+/g, ' ')
83
+ .trim();
84
+ if (!s) return fallback;
85
+ return s.length > MAX_NAME_LEN ? `${s.slice(0, MAX_NAME_LEN)}…` : s;
86
+ }
87
+
88
+ function shownNames(list: readonly string[] | undefined): string[] {
89
+ return (list ?? []).slice(0, SHOWN_NAMES).map((n) => safeName(n, '(unnamed)'));
90
+ }
91
+
92
+ /**
93
+ * The per-document counts, or null if they cannot be trusted.
94
+ *
95
+ * FAIL CLOSED. `/_sync-status` returns `JSON.parse` of `_sync.json` with no
96
+ * schema, so this function's input is whatever is on disk — a partial write, an
97
+ * older producer, a newer one. `synced` was the FALL-THROUGH branch, which made
98
+ * the reassuring answer the only reachable one for any shape not recognised:
99
+ * `docs: {}` gave `total === NaN`, failed both `> 0` and `=== 0`, and rendered
100
+ * "Synced — all NaN canvases", green. For a module whose entire premise is that
101
+ * it does not overstate, the unreadable case must land in `connecting`, never
102
+ * in `synced`.
103
+ */
104
+ function readCounts(
105
+ docs: SyncStatusLike['docs']
106
+ ): { synced: number; pending: number; rejected: number } | null {
107
+ if (!docs) return null;
108
+ const ok = (n: unknown): n is number => typeof n === 'number' && Number.isInteger(n) && n >= 0;
109
+ if (!ok(docs.synced) || !ok(docs.pending) || !ok(docs.rejected)) return null;
110
+ return { synced: docs.synced, pending: docs.pending, rejected: docs.rejected };
111
+ }
112
+
113
+ /**
114
+ * Read a sync payload the way a person would.
115
+ *
116
+ * Returns null when there is nothing to say — an unlinked project has no hub
117
+ * status, and inventing one would put a slot on screen that can only ever be
118
+ * empty.
119
+ */
120
+ export function syncPresentation(
121
+ status: SyncStatusLike | null | undefined,
122
+ opts: { project?: string | null } = {}
123
+ ): SyncPresentation | null {
124
+ if (!status || status.linked === false) return null;
125
+ const project = safeName(opts.project, 'the workspace');
126
+
127
+ if (status.notSyncable) {
128
+ const tsx = status.tsxCount ?? 0;
129
+ return {
130
+ phase: 'nothing-syncable',
131
+ online: false,
132
+ label: `0 syncable${tsx > 0 ? ` · ${tsx} tsx` : ''}`,
133
+ title: status.reason
134
+ ? safeName(status.reason, 'No canvases in this project are syncable yet.')
135
+ : `Connected to ${project} — no canvases here are syncable yet.`,
136
+ next: 'Create a canvas — it will start syncing on its own.',
137
+ names: [],
138
+ };
139
+ }
140
+
141
+ const queued = status.queuedOps ?? 0;
142
+ const queueNote = queued > 0 ? ` ${queued} local edit${queued === 1 ? '' : 's'} are queued.` : '';
143
+ const unreachable = status.state === 'offline' || status.state === 'offline-long';
144
+ // Validated, not taken on trust — see `readCounts`. `null` means "unreadable",
145
+ // which is deliberately NOT the same as "absent" (an old payload, handled
146
+ // below) and must never reach the synced branch.
147
+ const docs = readCounts(status.docs);
148
+ const unreadable = status.docs !== undefined && docs === null;
149
+
150
+ // A REFUSAL OUTRANKS EVERYTHING, including an unreachable hub.
151
+ //
152
+ // The offline branch used to sit above this one, and that was the bug from
153
+ // one branch up: a hub can refuse auth on the documents it wants silenced and
154
+ // then drop the sockets, and the user would be told "your edits are safe and
155
+ // queued — nothing to do". For an auth-rejected document that is false and
156
+ // never self-heals. Rejections are deliberately sticky (a dropped socket does
157
+ // not launder them), so if one is on record it is still true while offline —
158
+ // and it is the only part of the picture that needs a person.
159
+ if (docs && docs.rejected > 0) {
160
+ const total = docs.synced + docs.pending + docs.rejected;
161
+ return {
162
+ phase: 'refused',
163
+ online: false,
164
+ label: `${docs.rejected} refused`,
165
+ title:
166
+ `${docs.rejected} of ${total} canvas${total === 1 ? '' : 'es'} were refused by ${project} — their edits are not syncing.` +
167
+ (unreachable ? ' The hub is also unreachable right now.' : ''),
168
+ next: 'Reconnect the workspace — the credential may have been rotated.',
169
+ names: shownNames(status.rejectedSlugs),
170
+ };
171
+ }
172
+
173
+ // Otherwise an unreachable hub outranks every count. Whatever the documents
174
+ // last said, nothing is moving — and "72 synced" over a dead socket is the
175
+ // exact shape of lie this module exists to stop.
176
+ //
177
+ // Count first, name second: `project` is hub-supplied, and a leading name is
178
+ // a name that gets to set the tone of our own sentence.
179
+ if (unreachable) {
180
+ return {
181
+ phase: 'offline',
182
+ online: false,
183
+ label: queued > 0 ? `offline · ${queued} ↑` : 'offline',
184
+ title: `Not reachable: ${project}. Your edits are safe on this machine and queued.${queueNote}`,
185
+ next: 'Nothing to do — syncing resumes by itself when the hub is back.',
186
+ names: [],
187
+ };
188
+ }
189
+
190
+ if (unreadable) {
191
+ // A payload we cannot read is not a payload that says everything is fine.
192
+ return {
193
+ phase: 'connecting',
194
+ online: false,
195
+ label: 'status unreadable',
196
+ title: `Cannot read the sync status for ${project} — the counts on disk are malformed.`,
197
+ next: 'Reconnect the workspace; if it persists, restart Maude.',
198
+ names: [],
199
+ };
200
+ }
201
+
202
+ if (!docs) {
203
+ // Pre-DDR-102 payload: `state` is all there is. Report it as the weaker
204
+ // evidence it is, rather than promoting it to a document-level claim.
205
+ const online = status.state === 'online' || status.flash === 'synced';
206
+ return {
207
+ phase: online ? 'synced' : 'connecting',
208
+ online,
209
+ label: online ? (queued > 0 ? `${queued} ↑` : 'synced') : 'connecting…',
210
+ title: online ? `Syncing with ${project}.${queueNote}` : `Connecting to ${project}…`,
211
+ next: online ? null : 'Nothing to do — this usually takes a moment.',
212
+ names: [],
213
+ };
214
+ }
215
+
216
+ // Refusals were already handled above — they outrank an unreachable hub, so
217
+ // that branch has to come first. Everything from here on has `rejected === 0`.
218
+ const total = docs.synced + docs.pending + docs.rejected;
219
+
220
+ if (total === 0) {
221
+ return {
222
+ phase: 'connecting',
223
+ online: false,
224
+ label: 'connecting…',
225
+ title: `Connecting to ${project}…`,
226
+ next: 'Nothing to do — this usually takes a moment.',
227
+ names: [],
228
+ };
229
+ }
230
+
231
+ if (docs.pending > 0) {
232
+ // Zero settled yet is a different fact from some settled: one is a
233
+ // handshake in flight, the other is visible progress.
234
+ const started = docs.synced > 0;
235
+ return {
236
+ phase: started ? 'syncing' : 'connecting',
237
+ online: false,
238
+ label: started ? `${docs.synced}/${total}` : 'connecting…',
239
+ title: started
240
+ ? `Syncing with ${project} — ${docs.synced} of ${total} canvas${total === 1 ? '' : 'es'} so far.`
241
+ : `Connecting to ${project}…`,
242
+ next: 'Nothing to do — this usually takes a moment.',
243
+ names: [],
244
+ };
245
+ }
246
+
247
+ // Clamped and validated for the same reason as the doc counts: `count` is
248
+ // read off disk, and "1000000000 came down from the project" is not a
249
+ // sentence this module should be capable of producing. The ceiling is `total`
250
+ // — a pulled document becomes one of the canvases being counted, so more
251
+ // pulled than exist is not a big number, it is a broken payload.
252
+ const rawPulled = status.pulled?.count;
253
+ const pulledCount =
254
+ typeof rawPulled === 'number' && Number.isInteger(rawPulled) && rawPulled >= 0
255
+ ? Math.min(rawPulled, total)
256
+ : 0;
257
+ return {
258
+ phase: 'synced',
259
+ online: true,
260
+ label: queued > 0 ? `${queued} ↑` : 'synced',
261
+ title:
262
+ `Synced with ${project} — all ${total} canvas${total === 1 ? '' : 'es'}.` +
263
+ (pulledCount > 0 ? ` ${pulledCount} came down from the project on this connect.` : '') +
264
+ queueNote,
265
+ // Deliberately NOT "open one of the canvases that just arrived". A pulled
266
+ // canvas is hub-authored TSX (DDR-054, and the DDR-079 residual it carries),
267
+ // and an imperative in our own green success sentence is the strongest
268
+ // possible endorsement of content we do not vouch for. State the fact; the
269
+ // decision to open stays the person's.
270
+ next: pulledCount > 0 ? 'They are new to this machine — look them over before editing.' : null,
271
+ names: pulledCount > 0 ? shownNames(status.pulled?.names) : [],
272
+ };
273
+ }
@@ -0,0 +1,191 @@
1
+ // What the PROJECT holds, versus what this machine happens to carry.
2
+ //
3
+ // The sync runtime opens one provider per canvas found on LOCAL disk, and Yjs
4
+ // has no enumeration — so a document that exists only on the hub is invisible
5
+ // to a peer by construction. A desktop with 72 of a project's 75 canvases syncs
6
+ // 72, reports "72/72 synced", and is accurate about the wrong universe. The
7
+ // user-visible form of that was "I pressed Open in Maude and nothing happened":
8
+ // three real canvases (welcome, how-to-use, how-to-make-video) lived only in
9
+ // the cloud and could never arrive.
10
+ //
11
+ // This module asks the hub what it has (`GET /api/documents`, scope-bound to
12
+ // the same token the sync uses) and diffs it against the local set, so the
13
+ // runtime can say which side is missing what.
14
+ //
15
+ // SYNC IS BIDIRECTIONAL AND COMPLETE. A project you have been granted access to
16
+ // is a project you get — all of it, both directions. Local canvases go up (they
17
+ // always did); hub-only documents now come DOWN, materialised as real files.
18
+ //
19
+ // What that trades, stated plainly: a hub can create files in your design root,
20
+ // where before it could only update canvases you already had (DDR-054 treats
21
+ // hub-pushed content as untrusted). That is accepted deliberately — the hub is
22
+ // the project, the caller is authenticated, and a partial project is not a
23
+ // project. The one guard kept is containment: a document NAME can never place a
24
+ // file outside the design root. That is not policy filtering, it is the
25
+ // difference between writing your project and writing your filesystem.
26
+
27
+ /** One document as the hub reports it. */
28
+ export interface RemoteDoc {
29
+ name: string;
30
+ bytes: number;
31
+ }
32
+
33
+ export interface RemoteDocDiff {
34
+ /** Documents on both sides — the ones actually syncing. */
35
+ shared: string[];
36
+ /** On the hub, absent here. The project has them; this machine cannot get them. */
37
+ hubOnly: RemoteDoc[];
38
+ /** Here, not on the hub yet — normal for a canvas this peer just created. */
39
+ localOnly: string[];
40
+ /** Null when the hub could not be asked (old hub, offline, refused). */
41
+ reachable: boolean;
42
+ }
43
+
44
+ /** How long to wait for the listing. Never blocks a sync — see `fetchRemoteDocs`. */
45
+ const LIST_TIMEOUT_MS = 6000;
46
+
47
+ /**
48
+ * Ask the hub which documents this token may open.
49
+ *
50
+ * Returns null on ANY failure — an old hub without the route, a refused token,
51
+ * a network blip. A peer that cannot get the listing must still sync: this is a
52
+ * reporting improvement, and making it load-bearing would trade a real feature
53
+ * for a nicer message.
54
+ */
55
+ export async function fetchRemoteDocs(
56
+ hubUrl: string,
57
+ token: string,
58
+ fetchImpl: typeof fetch = fetch
59
+ ): Promise<RemoteDoc[] | null> {
60
+ try {
61
+ const base = hubUrl.replace(/\/+$/, '');
62
+ const res = await fetchImpl(`${base}/api/documents`, {
63
+ headers: { authorization: `Bearer ${token}` },
64
+ signal: AbortSignal.timeout(LIST_TIMEOUT_MS),
65
+ });
66
+ if (!res.ok) return null;
67
+ const body = (await res.json()) as { documents?: unknown };
68
+ if (!Array.isArray(body?.documents)) return null;
69
+ return body.documents
70
+ .filter(
71
+ (d): d is RemoteDoc =>
72
+ !!d && typeof (d as RemoteDoc).name === 'string' && (d as RemoteDoc).name.length > 0
73
+ )
74
+ .map((d) => ({ name: d.name, bytes: Number(d.bytes) || 0 }));
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Diff the hub's documents against the ones this peer syncs.
82
+ *
83
+ * `localDocNames` must already be in WIRE form (`docNameFor(slug)`), not raw
84
+ * slugs — comparing a namespaced hub against flat local names would report
85
+ * every document as missing on both sides, which is the exact failure the
86
+ * namespace exists to prevent, arrived at from the reporting side.
87
+ */
88
+ export function diffRemoteDocs(
89
+ localDocNames: readonly string[],
90
+ remote: RemoteDoc[] | null
91
+ ): RemoteDocDiff {
92
+ if (remote === null) {
93
+ return { shared: [], hubOnly: [], localOnly: [...localDocNames], reachable: false };
94
+ }
95
+ const local = new Set(localDocNames);
96
+ const remoteNames = new Set(remote.map((d) => d.name));
97
+ return {
98
+ shared: [...local].filter((n) => remoteNames.has(n)).sort(),
99
+ hubOnly: remote.filter((d) => !local.has(d.name)).sort((a, b) => a.name.localeCompare(b.name)),
100
+ localOnly: [...local].filter((n) => !remoteNames.has(n)).sort(),
101
+ reachable: true,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * One line a person can act on.
107
+ *
108
+ * Deliberately names the documents being PULLED rather than only what already
109
+ * matched: "72 synced" was true and useless, because the number is drawn from
110
+ * the local file set and can never express what the project holds beyond it.
111
+ */
112
+ export function describeRemoteDiff(diff: RemoteDocDiff): string | null {
113
+ if (!diff.reachable) return null;
114
+ if (diff.hubOnly.length === 0) return null;
115
+ const n = diff.hubOnly.length;
116
+ const names = diff.hubOnly
117
+ .slice(0, 3)
118
+ .map((d) => d.name)
119
+ .join(', ');
120
+ return `pulling ${n} canvas${n === 1 ? '' : 'es'} down from the project (${names}${n > 3 ? ', …' : ''}).`;
121
+ }
122
+
123
+ /**
124
+ * The local slug a hub document name maps to.
125
+ *
126
+ * Namespaced names (`ws/<workspace>/<branch>/<slug>` — DDR-192 §5) carry the
127
+ * slug in the last segment; a legacy flat name IS the slug. Returns null for
128
+ * anything that does not survive the charset the hub itself enforces on
129
+ * `documentName`, so a crafted name cannot become a path component.
130
+ */
131
+ export function slugFromDocName(docName: string): string | null {
132
+ const raw = String(docName ?? '');
133
+ // Validate the WHOLE name against the two shapes a hub may legitimately use,
134
+ // never just its last segment. Taking the tail of an arbitrary string would
135
+ // accept `../../etc/passwd` as `passwd`: contained by the check below, and
136
+ // still a file this project never asked for, created from a name that should
137
+ // have been refused outright. A component is `[A-Za-z0-9_-]` — no dots (so no
138
+ // traversal and no extension smuggling), no spaces.
139
+ const COMPONENT = '[A-Za-z0-9_-]{1,120}';
140
+ const FLAT = new RegExp(`^${COMPONENT}$`);
141
+ const NAMESPACED = new RegExp(`^ws/${COMPONENT}/${COMPONENT}/(${COMPONENT})$`);
142
+ if (FLAT.test(raw)) return raw.toLowerCase();
143
+ const ns = NAMESPACED.exec(raw);
144
+ return ns ? ns[1].toLowerCase() : null;
145
+ }
146
+
147
+ export interface PullTarget {
148
+ slug: string;
149
+ docName: string;
150
+ /** Absolute path the body will be written to, flat under the design root. */
151
+ bodyAbs: string;
152
+ }
153
+
154
+ /**
155
+ * Where a hub-only document lands on disk.
156
+ *
157
+ * FLAT, directly under the design root — the same convention the hub applies in
158
+ * the mirror direction (`workspace-files.mjs defaultBodyPath`) and for the same
159
+ * reason: a slug is lossy, `ui-card` cannot be un-flattened into `ui/Card.tsx`
160
+ * without guessing, and guessing wrong scatters files into directories the user
161
+ * never made. A flat file is trivially moved; an invented tree is not. Moving it
162
+ * later does not break sync — both paths slug to the same document.
163
+ *
164
+ * Returns only targets that resolve INSIDE the design root. A document name is
165
+ * hub-controlled input, and this is the last point before a create.
166
+ */
167
+ export function pullTargets(
168
+ hubOnly: readonly RemoteDoc[],
169
+ designRoot: string,
170
+ join: (...parts: string[]) => string,
171
+ resolve: (p: string) => string,
172
+ sep: string
173
+ ): PullTarget[] {
174
+ const rootResolved = resolve(designRoot);
175
+ const out: PullTarget[] = [];
176
+ // One slug, one target. `slugFromDocName` lowercases, so a hub advertising
177
+ // `Foo`, `foo` and `ws/w/main/foo` yields three targets for ONE file — three
178
+ // providers on the same path, and a `pulled` count that overstates what
179
+ // arrived by a factor the hub chooses.
180
+ const seen = new Set<string>();
181
+ for (const doc of hubOnly) {
182
+ const slug = slugFromDocName(doc.name);
183
+ if (!slug || seen.has(slug)) continue;
184
+ seen.add(slug);
185
+ const bodyAbs = join(designRoot, `${slug}.tsx`);
186
+ const target = resolve(bodyAbs);
187
+ if (target !== rootResolved && !target.startsWith(rootResolved + sep)) continue;
188
+ out.push({ slug, docName: doc.name, bodyAbs });
189
+ }
190
+ return out;
191
+ }
@@ -79,7 +79,10 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
79
79
  const conflicts: SyncConflict[] = [];
80
80
 
81
81
  let snapshot: SyncStatusSnapshot = {
82
- state: 'online',
82
+ // Same reason the monitor is seeded `connecting` (see connection-state.ts):
83
+ // this is the payload a reader gets BEFORE the first monitor snapshot lands,
84
+ // so seeding it `online` re-introduced the born-connected lie one layer out.
85
+ state: 'connecting',
83
86
  queuedOps: 0,
84
87
  lastSyncAt: null,
85
88
  offlineSince: null,
@@ -0,0 +1,183 @@
1
+ // feature-acp-turn-notifications Task 2/3 — the activity snapshot the native
2
+ // shell's poller reads (notify.rs Task 5) to decide whether to fire an OS
3
+ // notification for a project that isn't the visible one. Covers:
4
+ // • activitySnapshot() shape + monotonic seq (bumps only on real change).
5
+ // • AcpBridge.awaitingInputCount reflects pending permission requests.
6
+ // • The Acp manager's registered probe reports 'awaiting-input', not
7
+ // 'running', for a chat blocked on a permission prompt — the ordering
8
+ // Task 2's gotcha calls out explicitly.
9
+ // • /_api/acp/activity is absent from both canvas-origin allowlists
10
+ // (Decision: NEITHER, like /_api/acp/running today).
11
+
12
+ import { afterEach, describe, expect, test } from 'bun:test';
13
+ import { readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ import type { ServerWebSocket } from 'bun';
17
+
18
+ import { AcpBridge } from '../acp/bridge.ts';
19
+ import { createAcp } from '../acp/index.ts';
20
+ import { activitySnapshot, registerActivityProbe } from '../acp/running.ts';
21
+ import type { Context } from '../context.ts';
22
+ import type { WsData } from '../ws.ts';
23
+
24
+ const FIXTURE = join(import.meta.dir, 'fixtures', 'mock-acp-agent-permission.mjs');
25
+ const TEST_ENV_KEYS = ['MAUDE_ACP_ADAPTER_ENTRY', 'MAUDE_ACP_RUNTIME', 'MAUDE_CLAUDE_BIN'];
26
+
27
+ function useMockAgent() {
28
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
29
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
30
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
31
+ }
32
+
33
+ afterEach(() => {
34
+ for (const key of TEST_ENV_KEYS) delete process.env[key];
35
+ // Restore a no-op probe so a later file's `registerRunningChatsProbe`-style
36
+ // module-level state doesn't leak a stale closure across test files.
37
+ registerActivityProbe(() => []);
38
+ });
39
+
40
+ async function until<T>(fn: () => T | undefined, timeoutMs = 12000): Promise<T> {
41
+ const start = performance.now();
42
+ for (;;) {
43
+ const v = fn();
44
+ if (v !== undefined) return v;
45
+ if (performance.now() - start > timeoutMs) throw new Error('timeout waiting for condition');
46
+ await new Promise((r) => setTimeout(r, 25));
47
+ }
48
+ }
49
+
50
+ function fakeWs(id: string) {
51
+ const frames: Array<Record<string, unknown>> = [];
52
+ const ws = {
53
+ data: { id } as WsData,
54
+ send: (raw: string) => {
55
+ frames.push(JSON.parse(raw));
56
+ },
57
+ } as unknown as ServerWebSocket<WsData>;
58
+ return { ws, frames };
59
+ }
60
+
61
+ describe('activitySnapshot (running.ts)', () => {
62
+ test('reflects the registered probe and never throws on a bad one', () => {
63
+ registerActivityProbe(() => [{ chatId: 'a', state: 'running' }]);
64
+ expect(activitySnapshot().chats).toEqual([{ chatId: 'a', state: 'running' }]);
65
+
66
+ registerActivityProbe(() => {
67
+ throw new Error('boom');
68
+ });
69
+ expect(activitySnapshot().chats).toEqual([]);
70
+ });
71
+
72
+ test('seq bumps only when the (chatId, state) set actually changes', () => {
73
+ registerActivityProbe(() => [{ chatId: 'a', state: 'idle' }]);
74
+ const first = activitySnapshot().seq;
75
+ const second = activitySnapshot().seq; // same state — no bump
76
+ expect(second).toBe(first);
77
+
78
+ registerActivityProbe(() => [{ chatId: 'a', state: 'running' }]);
79
+ const third = activitySnapshot().seq; // real transition — bumps
80
+ expect(third).toBe(first + 1);
81
+
82
+ // Enumeration-order shuffle with the SAME states must NOT bump.
83
+ registerActivityProbe(() => [
84
+ { chatId: 'b', state: 'idle' },
85
+ { chatId: 'a', state: 'running' },
86
+ ]);
87
+ activitySnapshot();
88
+ registerActivityProbe(() => [
89
+ { chatId: 'a', state: 'running' },
90
+ { chatId: 'b', state: 'idle' },
91
+ ]);
92
+ const fourth = activitySnapshot().seq;
93
+ const fifth = activitySnapshot().seq;
94
+ expect(fifth).toBe(fourth);
95
+ });
96
+ });
97
+
98
+ describe('AcpBridge.awaitingInputCount + the manager probe (Task 2 gotcha: awaiting-input wins over running)', () => {
99
+ test('a pending permission request reports awaiting-input, not running', async () => {
100
+ useMockAgent();
101
+ const ctx = {
102
+ paths: { repoRoot: process.cwd(), designRoot: '/tmp/does-not-matter-activity' },
103
+ } as unknown as Context;
104
+ const acp = createAcp(ctx);
105
+
106
+ const a = fakeWs('activity-ws-a');
107
+ try {
108
+ acp.onOpen(a.ws);
109
+ acp.onMessage(a.ws, JSON.stringify({ t: 'prompt', text: 'hi', chat: 'activity-chat' }));
110
+ const reqFrame = await until(() => a.frames.find((f) => f.t === 'permission-request'));
111
+
112
+ const snap = activitySnapshot();
113
+ expect(snap.chats).toContainEqual({ chatId: 'activity-chat', state: 'awaiting-input' });
114
+
115
+ acp.onMessage(
116
+ a.ws,
117
+ JSON.stringify({ t: 'permission-response', id: reqFrame.id, decision: 'allow-once' })
118
+ );
119
+ await until(() => a.frames.find((f) => f.t === 'turn-end'));
120
+
121
+ // Turn settled — no longer awaiting-input (running or idle, never stuck).
122
+ const after = activitySnapshot().chats.find((c) => c.chatId === 'activity-chat');
123
+ expect(after?.state).not.toBe('awaiting-input');
124
+ } finally {
125
+ acp.onClose(a.ws);
126
+ acp.stopAll();
127
+ await new Promise((r) => setTimeout(r, 50));
128
+ }
129
+ }, 20000);
130
+ });
131
+
132
+ describe('AcpBridge.awaitingInputCount — direct unit', () => {
133
+ test('starts at 0 and counts a pending permission before resolution', async () => {
134
+ useMockAgent();
135
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
136
+ expect(bridge.awaitingInputCount).toBe(0);
137
+ try {
138
+ const promptPromise = bridge.prompt('hi', 'c1');
139
+ await until(() => (bridge.awaitingInputCount > 0 ? true : undefined));
140
+ expect(bridge.awaitingInputCount).toBe(1);
141
+ await bridge.cancel(); // denies the pending request rather than hanging
142
+ await promptPromise;
143
+ expect(bridge.awaitingInputCount).toBe(0);
144
+ } finally {
145
+ await bridge.stop();
146
+ }
147
+ }, 15000);
148
+ });
149
+
150
+ describe('/_api/acp/activity — NEITHER canvas-origin allowlist (Decision D + dual-allowlist rule)', () => {
151
+ const httpSrc = readFileSync(join(import.meta.dir, '..', 'http.ts'), 'utf8');
152
+ const serverSrc = readFileSync(join(import.meta.dir, '..', 'server.ts'), 'utf8');
153
+
154
+ test('the route exists on the main-origin route table', () => {
155
+ expect(httpSrc).toContain("'/_api/acp/activity'");
156
+ });
157
+
158
+ test('is absent from CANVAS_SAFE_API', () => {
159
+ const start = httpSrc.indexOf('CANVAS_SAFE_API = new Set([');
160
+ const end = httpSrc.indexOf(']);', start);
161
+ expect(start).toBeGreaterThan(-1);
162
+ const block = httpSrc.slice(start, end);
163
+ expect(block).not.toContain('/_api/acp/activity');
164
+ });
165
+
166
+ test("is absent from startCanvasServer's routes map", () => {
167
+ const start = serverSrc.indexOf('function startCanvasServer');
168
+ const routesEnd = serverSrc.indexOf('async fetch(req, srv)', start);
169
+ expect(start).toBeGreaterThan(-1);
170
+ const block = serverSrc.slice(start, routesEnd);
171
+ expect(block).not.toContain('/_api/acp/activity');
172
+ });
173
+
174
+ test('the payload never carries a chat title, message text, or transcript field name', () => {
175
+ // Grep the route handler body itself (not the whole file) for the fields
176
+ // Decision D forbids — a title/text field appearing HERE would mean
177
+ // someone widened the payload without reading the comment.
178
+ const start = httpSrc.indexOf("'/_api/acp/activity':");
179
+ const end = httpSrc.indexOf('},', start);
180
+ const block = httpSrc.slice(start, end);
181
+ expect(block).not.toMatch(/title|message|transcript|content/i);
182
+ });
183
+ });