@intentic/extension-api 1.248.0 → 1.250.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/dist/api.d.ts +9 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/background.d.ts.map +1 -1
- package/dist/background.js.map +1 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/facts.d.ts.map +1 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js.map +1 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js.map +1 -1
- package/dist/route.d.ts.map +1 -1
- package/dist/route.js.map +1 -1
- package/dist/scope.d.ts.map +1 -1
- package/dist/scope.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/stream.d.ts.map +1 -1
- package/dist/stream.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +3 -3
- package/src/api.ts +154 -384
- package/src/background.ts +25 -127
- package/src/diff.ts +16 -47
- package/src/facts.ts +9 -22
- package/src/host.ts +3 -13
- package/src/protocol.ts +3 -15
- package/src/route.ts +6 -14
- package/src/scope.ts +12 -73
- package/src/server.ts +14 -39
- package/src/stream.ts +7 -12
- package/src/surface.json +84 -0
- package/src/version.ts +6 -1
package/src/background.ts
CHANGED
|
@@ -2,106 +2,39 @@ import type { Ref } from "vue";
|
|
|
2
2
|
import type { Disposable, IntenticApi } from "./api.js";
|
|
3
3
|
import { sandboxRef, sandboxScopeGuard } from "./scope.js";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
* A tile has to be able to say something before it is opened. That rules out the view's own query, which stops
|
|
9
|
-
* when the component unmounts, and it rules out the file-change push AS AN INVALIDATION, because evicting a
|
|
10
|
-
* cache entry only reaches a query something is observing. So the state lives at module scope (scope.ts) and
|
|
11
|
-
* something refreshes it.
|
|
12
|
-
*
|
|
13
|
-
* THAT SOMETHING IS THE WRITE ITSELF WHEREVER THE ANSWER LIVES IN A FILE, and the timer is the backstop. The
|
|
14
|
-
* push was already arriving and already naming this extension's paths, it was simply spent entirely on the cache
|
|
15
|
-
* (`api.workspace.onDidChangeFiles`), so every badge in the workspace was as fresh as its own interval and no
|
|
16
|
-
* fresher: an approvals queue the owner had just emptied went on claiming six items for a minute, and the slowest
|
|
17
|
-
* tile here is ten minutes behind the file it describes. A count that is wrong for minutes at a time is worse
|
|
18
|
-
* than no count, because the owner learns to distrust the one they cannot check without clicking.
|
|
19
|
-
*
|
|
20
|
-
* What the interval is still FOR, and why it did not simply go away: a source that is not a file at all (a CI
|
|
21
|
-
* provider, a Komodo deployment) has nothing to push, so for those it remains the only feed; and for the rest it
|
|
22
|
-
* covers the gap no push can close, a watcher that dropped an event, an exclusion nobody noticed. Where a file
|
|
23
|
-
* binding carries the news, `everyMs` is honestly a slow safety net and the extensions here say so.
|
|
24
|
-
*
|
|
25
|
-
* Seven modules across six extensions arrived at the identical shape for that timer, and it carries five rules
|
|
26
|
-
* that are each invisible until they are broken:
|
|
27
|
-
*
|
|
28
|
-
* never reject it runs detached, so a throw is an unhandled rejection with no caller to report to, and
|
|
29
|
-
* that includes reading the host handle, which throws before activate() has bound one
|
|
30
|
-
* skip when down an unreachable daemon is not news; asking it is a failed request per tick, forever
|
|
31
|
-
* guard the await a read issued before a sandbox switch must not write its answer into the box after it
|
|
32
|
-
* keep the last a transient failure is not evidence that nothing is waiting, so a failed read changes
|
|
33
|
-
* nothing rather than blanking the tile
|
|
34
|
-
* stop the clock the interval is disposed with the extension, or a switched-off extension keeps polling
|
|
35
|
-
*
|
|
36
|
-
* Six of the seven copies got the third one wrong, which is what this pair exists to make impossible. What is
|
|
37
|
-
* NOT here is what each tile SAYS: the count, the tone and the wording are the whole point of each surface and
|
|
38
|
-
* differ deliberately, so `badge()` stays with the extension that owns the judgement. */
|
|
5
|
+
// Sandbox-scoped background polling for a badge that needs an answer before its tile opens: state lives at module
|
|
6
|
+
// scope, refreshed by a coalesced interval and a file-change wake. A failed read keeps the last value; a read is
|
|
7
|
+
// dropped if the sandbox switched while it was in flight.
|
|
39
8
|
|
|
40
9
|
export interface SandboxPoll<T> {
|
|
41
|
-
|
|
42
|
-
* own computed repaints when it moves; write to it directly for the local fold a "mark as seen" does, which
|
|
43
|
-
* is what clears a tile on the spot instead of at the next tick. */
|
|
10
|
+
// Sandbox-scoped state (scope.ts); write directly to fold in a local change like "mark as seen".
|
|
44
11
|
readonly state: Ref<T>;
|
|
45
|
-
|
|
46
|
-
* Disposable onto `context.subscriptions` and both stop with the extension.
|
|
47
|
-
*
|
|
48
|
-
* The file wake needs nothing declared here. An extension that named the paths its views derive from has
|
|
49
|
-
* already said which writes change this answer, so subscribing is unconditional and an extension that
|
|
50
|
-
* declared none is simply never woken.
|
|
51
|
-
*/
|
|
12
|
+
// Begins reading on the interval and on the extension's own file writes; push onto context.subscriptions.
|
|
52
13
|
start(): Disposable;
|
|
53
|
-
//
|
|
54
|
-
// connection appearing, a draft published, a run discarded.
|
|
14
|
+
// Reads immediately, off-cycle, for a moment that should not wait for the interval.
|
|
55
15
|
refresh(): void;
|
|
56
16
|
}
|
|
57
17
|
|
|
58
18
|
export interface SandboxPollOptions<T> {
|
|
59
|
-
//
|
|
60
|
-
// at module load and nothing is bound until activate() runs.
|
|
19
|
+
// A function, not the api itself: nothing is bound until activate() runs.
|
|
61
20
|
readonly host: () => IntenticApi;
|
|
62
|
-
|
|
63
|
-
* the answer actually changes, and a surface that has not thought about it will inherit whatever number
|
|
64
|
-
* happened to be chosen here. A badge is glanced at, so the honest range is minutes, not seconds.
|
|
65
|
-
*
|
|
66
|
-
* Read it against `start()`'s file wake. If the answer lives in a path your manifest declares, the write
|
|
67
|
-
* already refreshes this and the interval is a BACKSTOP for the frame nobody delivered, so the honest number
|
|
68
|
-
* there is slow. If it lives behind somebody else's API, nothing pushes and this is the only feed. */
|
|
21
|
+
// No default: choose an interval that reflects how fast the answer actually changes.
|
|
69
22
|
readonly everyMs: number;
|
|
70
23
|
// The value before anything has been read, rebuilt on every sandbox switch (sandboxRef).
|
|
71
24
|
readonly initial: () => T;
|
|
72
|
-
|
|
73
|
-
* replaces, where one failed source must leave its own last answer standing beside the others. Throwing is
|
|
74
|
-
* fine and means "nothing changed": the value in hand is kept.
|
|
75
|
-
*/
|
|
25
|
+
// Gets the api and the value held (for an accumulating poll); throwing means nothing changed, value kept.
|
|
76
26
|
readonly read: (api: IntenticApi, previous: T) => Promise<T>;
|
|
77
|
-
|
|
78
|
-
* badges a minute after login is a tile nobody trusts. Set false when the poll has nothing to ask until
|
|
79
|
-
* something else tells it what to ask about, deployments learns its connections from `detect()`. */
|
|
27
|
+
// Defaults to reading immediately too; set false when there's nothing to ask until told (detect()).
|
|
80
28
|
readonly immediate?: boolean;
|
|
81
|
-
// For a value that owns something the garbage collector will not take back
|
|
29
|
+
// For a value that owns something the garbage collector will not take back.
|
|
82
30
|
readonly dispose?: (previous: T) => void;
|
|
83
31
|
}
|
|
84
32
|
|
|
85
|
-
|
|
86
|
-
* watcher at 250ms, but one logical event is often several batches, an acceptance run writing a result file per
|
|
87
|
-
* story, a publish rewriting a staging tree, so without this the widest scan in the workspace would be re-run
|
|
88
|
-
* per frame. Trailing rather than leading: the last write in a burst is the one whose answer is true.
|
|
89
|
-
*
|
|
90
|
-
* Short enough that the badge still moves while the owner is looking at the screen that caused the write, which
|
|
91
|
-
* is the entire point of not waiting for the interval. */
|
|
33
|
+
// How long a burst of writes may coalesce before the wake reads; trailing, since the last write wins.
|
|
92
34
|
const WAKE_MS = 400;
|
|
93
35
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
* Extensions activate together, so their minute clocks also fire together. Letting each timer issue its own
|
|
97
|
-
* request turned one harmless cadence into a burst across the daemon: the widest poll could still be walking
|
|
98
|
-
* the workspace while CI, deployments and chores all started beside it. A reconnect was worse, because every
|
|
99
|
-
* file-backed poll was woken in the same frame.
|
|
100
|
-
*
|
|
101
|
-
* Per-poll coalescing matters as much as the global lane. A wake that lands while its read is QUEUED adds no
|
|
102
|
-
* information; a wake that lands while it is RUNNING may describe a change the in-flight read missed, so it
|
|
103
|
-
* earns exactly one trailing pass. Re-queueing that pass at the back keeps one noisy poll from starving the
|
|
104
|
-
* others. */
|
|
36
|
+
// One shared read lane for the whole window, so simultaneous per-extension timers don't burst the daemon at once.
|
|
37
|
+
// Per-poll: a wake mid-queue is redundant, a wake mid-run earns exactly one trailing pass, requeued at the back.
|
|
105
38
|
interface ScheduledRead {
|
|
106
39
|
queued: boolean;
|
|
107
40
|
running: boolean;
|
|
@@ -154,12 +87,8 @@ const scheduleRead = (scheduled: ScheduledRead): void => {
|
|
|
154
87
|
void drainScheduledReads();
|
|
155
88
|
};
|
|
156
89
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
* Contained in a try/catch because BOTH of its failure modes are ordinary rather than exceptional: `host()`
|
|
160
|
-
* throws until activate() has bound a handle, and an older host has no `onDidChangeFiles` at all (this arrived in
|
|
161
|
-
* api 2.10.0, and an extension may declare `engines.intentic` wider than that). Either way the poll falls back to
|
|
162
|
-
* exactly the timer it had before, which is a slower badge and never a broken one. */
|
|
90
|
+
// Re-reads when the extension's declared files change. Wrapped in try/catch: host() throws before activate(), and an
|
|
91
|
+
// older host may lack onDidChangeFiles; either way it falls back to the timer alone.
|
|
163
92
|
const wakeOnFiles = (host: () => IntenticApi, read: () => void): Disposable => {
|
|
164
93
|
let pending: ReturnType<typeof setTimeout> | undefined;
|
|
165
94
|
let subscription: Disposable | undefined;
|
|
@@ -200,8 +129,7 @@ export const sandboxPoll = <T>(options: SandboxPollOptions<T>): SandboxPoll<T> =
|
|
|
200
129
|
}
|
|
201
130
|
state.value = next;
|
|
202
131
|
} catch {
|
|
203
|
-
//
|
|
204
|
-
// same: leave the last value standing. "We could not ask" is not "there is nothing there".
|
|
132
|
+
// Leave the last value standing; "could not ask" is not "nothing there".
|
|
205
133
|
}
|
|
206
134
|
};
|
|
207
135
|
const scheduled: ScheduledRead = { queued: false, running: false, trailing: false, read: once };
|
|
@@ -225,41 +153,14 @@ export const sandboxPoll = <T>(options: SandboxPollOptions<T>): SandboxPoll<T> =
|
|
|
225
153
|
};
|
|
226
154
|
};
|
|
227
155
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
* The rail's bar is that a badge means "something happened here that you do not already know about". Meeting it
|
|
231
|
-
* needs somewhere to record what they DO know, and three extensions independently chose the same home: a JSON
|
|
232
|
-
* object under `.intentic`, keyed by whatever identifies the thing. That is the right home, it survives a
|
|
233
|
-
* reload, it is shared across the owner's browsers, and it needs no setting nobody would ever type, but each
|
|
234
|
-
* of them then hand-wrote the same tolerant reader and the same careful write.
|
|
235
|
-
*
|
|
236
|
-
* KEY → MARK, where the mark is what makes the entry STALE. That is the whole vocabulary, and it covers both
|
|
237
|
-
* the ledgers that compare (a chore's evidence digest, a story's verdict, the same key with a different mark
|
|
238
|
-
* is news again) and the ones that only ask whether a key is present at all (a document set reviewed once).
|
|
239
|
-
* A presence-only ledger writes the acknowledgement time as its mark, which nothing reads and a human opening
|
|
240
|
-
* the file is glad of.
|
|
241
|
-
*
|
|
242
|
-
* The file is written by agents and editable by hand, so a missing, truncated or hand-mangled one reads as
|
|
243
|
-
* "nothing acknowledged". That direction is deliberate: bad bookkeeping may light a badge that should have been
|
|
244
|
-
* quiet, and must never hide one that should have been lit.
|
|
245
|
-
*
|
|
246
|
-
* BOTH WRITES ANSWER "did this take effect", which is the question the caller's NEXT line depends on. Marking
|
|
247
|
-
* something seen is almost always followed by folding it out of the badge locally, so the tile clears on the
|
|
248
|
-
* spot rather than at the next poll, and that fold is a write into sandbox-scoped state, so it must not happen
|
|
249
|
-
* when the acknowledgement itself was abandoned because the owner switched sandbox mid-operation. It would
|
|
250
|
-
* silence the NEW box's badge for a fact about the old one. `false` means only that: the scope moved. A ledger
|
|
251
|
-
* that already said what you asked it to say answers `true`, because it does. */
|
|
156
|
+
// Key → mark map of what the owner has already seen, stored as JSON under `.intentic`. Missing or unparseable reads as
|
|
157
|
+
// nothing acknowledged; a write's `false` return means only that the sandbox scope moved mid-write.
|
|
252
158
|
export interface SandboxLedger {
|
|
253
159
|
// Everything acknowledged so far. Absent, unparseable or not-an-object all read as nothing.
|
|
254
160
|
read(): Promise<Readonly<Record<string, string>>>;
|
|
255
|
-
|
|
256
|
-
* nothing moved: the file push would otherwise cost every connected browser a refetch for a file whose
|
|
257
|
-
* content is identical. */
|
|
161
|
+
// Records these, leaving every other entry alone; no write happens when nothing moved.
|
|
258
162
|
mark(entries: Readonly<Record<string, string>>): Promise<boolean>;
|
|
259
|
-
|
|
260
|
-
* run that has scrolled past the scan window can never be seen again, and merging forever would grow the
|
|
261
|
-
* file without bound. Same no-op-when-unchanged rule as `mark`.
|
|
262
|
-
*/
|
|
163
|
+
// Makes these the whole ledger, dropping anything not named; same no-op-when-unchanged rule as mark.
|
|
263
164
|
replace(entries: Readonly<Record<string, string>>): Promise<boolean>;
|
|
264
165
|
}
|
|
265
166
|
|
|
@@ -269,20 +170,17 @@ const sameEntries = (left: Readonly<Record<string, string>>, right: Readonly<Rec
|
|
|
269
170
|
export const sandboxLedger = (host: () => IntenticApi, path: string): SandboxLedger => {
|
|
270
171
|
const read = async (): Promise<Readonly<Record<string, string>>> => {
|
|
271
172
|
const parsed = await host().workspace.readJson<Record<string, unknown>>(path);
|
|
272
|
-
// Non-string values are dropped
|
|
273
|
-
// else's idea of what this file is for.
|
|
173
|
+
// Non-string values are dropped, not coerced: a mark is always a string.
|
|
274
174
|
return Object.fromEntries(Object.entries(parsed ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === `string`));
|
|
275
175
|
};
|
|
276
176
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
* mid-operation. Reading one workspace's acknowledgements and writing them into the tree of the workspace
|
|
280
|
-
* the owner has just moved to is bookkeeping filed in the wrong place, which no later poll corrects. */
|
|
177
|
+
// One writer for both mark and replace; the scope guard lives here, not at the call site, since a sandbox switch
|
|
178
|
+
// mid-write would file the acknowledgement into the workspace the owner just left.
|
|
281
179
|
const settle = async (next: (seen: Readonly<Record<string, string>>) => Readonly<Record<string, string>>): Promise<boolean> => {
|
|
282
180
|
const current = sandboxScopeGuard();
|
|
283
181
|
const seen = await read();
|
|
284
182
|
const wanted = next(seen);
|
|
285
|
-
// Already saying it
|
|
183
|
+
// Already saying it: nothing to write, and the caller's fold stays correct.
|
|
286
184
|
if (sameEntries(seen, wanted)) {
|
|
287
185
|
return true;
|
|
288
186
|
}
|
package/src/diff.ts
CHANGED
|
@@ -1,73 +1,42 @@
|
|
|
1
1
|
import type { PartialFileDiff } from "@intentic/sandbox-contract";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
* user has open. That strip is the host's, so an extension that has computed a before/after pair has nowhere to
|
|
7
|
-
* put it: a view renders inside its own frame, and a document provider inside its own tab. This is the way out,
|
|
8
|
-
* and it is the whole shape of the contribution, the extension says what changed, the host owns the tab, the
|
|
9
|
-
* viewer, the close orchestration and the dirty-buffer bookkeeping.
|
|
10
|
-
*
|
|
11
|
-
* It lives in the PUBLIC api package rather than in the app because the app is downstream of it: the workspace's
|
|
12
|
-
* own review surfaces build the identical payload, and having two spellings of it is how the two would drift. */
|
|
3
|
+
// The argument to `api.workspace.openDiff`: the extension says what changed, the host owns the tab, viewer and
|
|
4
|
+
// dirty-buffer bookkeeping. Lives in the public api package because the app's own review surfaces build the identical
|
|
5
|
+
// payload.
|
|
13
6
|
|
|
14
|
-
// Git's vocabulary for what happened to a file
|
|
15
|
-
//
|
|
16
|
-
// these as its own letter and colour.
|
|
7
|
+
// Git's vocabulary for what happened to a file; the host renders each as its own letter and colour. "conflicted" is
|
|
8
|
+
// git's unmerged state (`U`), not a kind of modification.
|
|
17
9
|
export type ChangeStatus = "added" | "modified" | "deleted" | "renamed" | "type-changed" | "conflicted";
|
|
18
10
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
* one no after, and the viewer then gives the side that does exist the whole pane instead of drawing an empty
|
|
22
|
-
* half beside it. */
|
|
11
|
+
// A binary diff ships its two sides as daemon URLs to fetch bytes from, not as content. An absent side means that side
|
|
12
|
+
// does not exist; the viewer gives the pane entirely to the side that does.
|
|
23
13
|
export interface DiffRawSides {
|
|
24
14
|
readonly beforeRaw?: string;
|
|
25
15
|
readonly afterRaw?: string;
|
|
26
16
|
}
|
|
27
17
|
|
|
28
18
|
export interface DiffPayload extends DiffRawSides {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
* already open rather than stacking a second copy of it. Pick something stable and collision-free; prefixing
|
|
32
|
-
* with the extension's own id is the safe habit. */
|
|
19
|
+
// Identity of the diff source (commit sha, snapshot id, `working:<repo>`); with `scope`/`path` it is the tab's
|
|
20
|
+
// identity, so a reopen focuses it.
|
|
33
21
|
readonly key: string;
|
|
34
22
|
// Which repo (or snapshot scope) the path is relative to, the other half of the tab identity.
|
|
35
23
|
readonly scope: string;
|
|
36
|
-
// The tab's label
|
|
24
|
+
// The tab's label; keep it short (e.g. "file.ts @ a1b2c3d"), the strip is narrow.
|
|
37
25
|
readonly label: string;
|
|
38
26
|
readonly status: ChangeStatus;
|
|
39
27
|
readonly path: string;
|
|
40
|
-
// The two sides as text
|
|
41
|
-
// `binary` and `partial` are what the viewer renders instead of an empty pane.
|
|
28
|
+
// The two sides as text; absent when a side doesn't exist or is binary/oversized (see `binary`/`partial`).
|
|
42
29
|
readonly before?: string;
|
|
43
30
|
readonly after?: string;
|
|
44
31
|
readonly binary?: boolean;
|
|
45
|
-
|
|
46
|
-
* sizes of the two sides. The host's viewer rebuilds a real diff out of it, with the file's own line
|
|
47
|
-
* numbers, so an extension that has computed a huge before/after pair has something better to offer than
|
|
48
|
-
* a refusal: set this and leave `before`/`after` off. See PartialFileDiff for how the patch is read. */
|
|
32
|
+
// A big file's changed regions (a unified patch) plus both sizes; set this instead of before/after.
|
|
49
33
|
readonly partial?: PartialFileDiff;
|
|
50
|
-
//
|
|
51
|
-
// where the source has no numstat to give (a binary file, a change list without line counts), the toolbar
|
|
52
|
-
// then renders nothing rather than a zero.
|
|
34
|
+
// Size already known by the row that opened this, shown on the tab's toolbar; absent renders nothing, not zero.
|
|
53
35
|
readonly additions?: number;
|
|
54
36
|
readonly deletions?: number;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
* Reading a file the source has to compute or fetch is a wait, and a wait that has nowhere to be drawn gets
|
|
58
|
-
* drawn on the click instead: the row is clicked, nothing on screen changes, and the reader clicks it again.
|
|
59
|
-
* Everything the tab needs to exist, its identity, its label, the status letter and the line counts, is
|
|
60
|
-
* known before the content is, so the tab opens on the click and the panes fill underneath it.
|
|
61
|
-
*
|
|
62
|
-
* Absent means the payload IS the content, which is what an extension handing over an already-computed
|
|
63
|
-
* before/after pair should send. */
|
|
37
|
+
// Opens the tab immediately and fills content later via `workspace.fillDiff`; absent means the payload is the
|
|
38
|
+
// content.
|
|
64
39
|
readonly pending?: boolean;
|
|
65
|
-
|
|
66
|
-
* next peek replaces it in place.
|
|
67
|
-
*
|
|
68
|
-
* It is the difference between a list being READ and a file being CHOSEN. An extension whose document lists
|
|
69
|
-
* changed files (a commit's, a run's) has a reader who will click ten of them in a row, and ten pinned tabs
|
|
70
|
-
* is what the slot exists to prevent, so those clicks are peeks and a double-click, which should send the
|
|
71
|
-
* payload without this, is what keeps one. Absent means keep, which is right for a single deliberate open. */
|
|
40
|
+
// A peek, not an open: reuses the strip's one transient tab slot instead of pinning a new one; absent keeps it.
|
|
72
41
|
readonly preview?: boolean;
|
|
73
42
|
}
|
package/src/facts.ts
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* `api.sandbox.request/json` against the `@intentic/sandbox-contract` schemas, the first-party wire contract,
|
|
5
|
-
* gated per-route by the manifest's `permissions.sandbox` allowlist. (Because every first-party extension is
|
|
6
|
-
* in-repo and compiled together, a wire change there is caught by the compiler and fixed atomically, so there is
|
|
7
|
-
* no separate "stable data API" to promote, only detection is version-stable.) The daemon's own summaries
|
|
8
|
-
* (PanelSummary, CapabilitySummary) are structural supersets and flow into `detect()` unmapped, but only THESE
|
|
9
|
-
* fields are guaranteed to it. Optional fields carry `| undefined` so zod-inferred wire types assign under
|
|
10
|
-
* exactOptionalPropertyTypes. */
|
|
1
|
+
// Stable detection facts `detect()` reads to decide when to activate a view: narrow and evidence-based, not the data
|
|
2
|
+
// plane (real data comes via `api.sandbox.request/json` against sandbox-contract schemas). Optional fields carry `|
|
|
3
|
+
// undefined` for exactOptionalPropertyTypes.
|
|
11
4
|
|
|
12
|
-
// Daemon-computed content facts for one
|
|
13
|
-
//
|
|
14
|
-
// + turbo.json, .intentic/ui), not what it happens to be named.
|
|
5
|
+
// Daemon-computed content facts for one repo under /work (`repo` is its root-relative dir); evidence over identity,
|
|
6
|
+
// based on what the repo contains, not its name.
|
|
15
7
|
export interface RepoFacts {
|
|
16
8
|
readonly repo: string;
|
|
17
9
|
// The workspace role this repo dir occupies; absent for extra clones.
|
|
@@ -23,19 +15,14 @@ export interface RepoFacts {
|
|
|
23
15
|
readonly directoryUi: boolean;
|
|
24
16
|
readonly monorepo: boolean;
|
|
25
17
|
readonly vitest: boolean;
|
|
26
|
-
// Whether the repo
|
|
27
|
-
// here that is language-agnostic, and the evidence an acceptance-testing surface activates on.
|
|
18
|
+
// Whether the repo has a docs/user-stories directory; what an acceptance-testing surface activates on.
|
|
28
19
|
readonly userStories: boolean;
|
|
29
|
-
|
|
30
|
-
*
|
|
31
|
-
* Here so that a surface which READS documentation can tell, without asking the file routes, which repos
|
|
32
|
-
* have any. The alternative was a read per repo on a poll, an answer the daemon already has from the same
|
|
33
|
-
* one-pass scan that produces every other fact on this interface. */
|
|
20
|
+
// Whether the repo has a docs/architecture directory.
|
|
34
21
|
readonly docs: boolean;
|
|
35
22
|
}
|
|
36
23
|
|
|
37
|
-
// One connected capability's secret-free echo
|
|
38
|
-
//
|
|
24
|
+
// One connected capability's secret-free echo. `kind` is an open string; matching on one couples the extension to it
|
|
25
|
+
// continuing to exist.
|
|
39
26
|
export interface CapabilityFacts {
|
|
40
27
|
readonly id: string;
|
|
41
28
|
readonly kind: string;
|
package/src/host.ts
CHANGED
|
@@ -1,18 +1,8 @@
|
|
|
1
1
|
import type { IntenticApi } from "./api.js";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
* through the public IntenticApi.
|
|
7
|
-
*
|
|
8
|
-
* A FACTORY rather than a module-level slot here, and that is the whole reason this lives in the API package
|
|
9
|
-
* instead of being one shared `host()`. The web shell publishes ONE instance of this module to every bundle
|
|
10
|
-
* (extension-host/hostModules.ts), so a slot held at module scope would be a single global that the last
|
|
11
|
-
* extension to activate silently takes over. Each extension calls this once and keeps its own closure:
|
|
12
|
-
*
|
|
13
|
-
* export const { bindHost, host } = hostSlot(`ext-activity`);
|
|
14
|
-
*
|
|
15
|
-
* The names come back already spelled the way call sites use them, so nothing downstream renames anything. */
|
|
3
|
+
// Per-extension host handle, ambient like `vscode.*`: `activate(api)` binds it once via `bindHost`, `host()` reads it.
|
|
4
|
+
// A factory, not a module-level slot: the web shell publishes one instance of this module to every extension's bundle,
|
|
5
|
+
// so a shared slot would be overwritten by whichever activates last.
|
|
16
6
|
export const hostSlot = (extension: string): { bindHost: (api: IntenticApi) => void; host: () => IntenticApi } => {
|
|
17
7
|
let current: IntenticApi | undefined;
|
|
18
8
|
return {
|
package/src/protocol.ts
CHANGED
|
@@ -1,18 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* The root barrel is the EXTENSION-facing surface, and an extension runs in the app, so the barrel is free to
|
|
5
|
-
* reach for vue: `scope.ts` imports `ref` as a value, not a type. The daemon is the other consumer, and it
|
|
6
|
-
* needs exactly these two names, to refuse an incompatible extension (backend-supervisor), to stamp the
|
|
7
|
-
* version it provides (backend-host-main), to write an engines range into a scaffold. It has no vue and no
|
|
8
|
-
* reason to grow one, but `export *` loads every re-exported module eagerly, so importing the barrel for
|
|
9
|
-
* `extensionApiVersion` alone dragged `vue` into a Node process that could not resolve it, the daemon crashed
|
|
10
|
-
* on boot and the sandbox never became healthy.
|
|
11
|
-
*
|
|
12
|
-
* Hence this entry point. `@intentic/extension-api/protocol` is what host-side Node code imports; the root
|
|
13
|
-
* barrel keeps exporting both names too, so nothing about the published extension surface changes. Type-only
|
|
14
|
-
* imports from the root (`ExtensionServerApi` and friends) stay where they are, those are erased at compile
|
|
15
|
-
* time and never reach the loader. */
|
|
1
|
+
// Node-safe entry point for `satisfiesEngines`/`extensionApiVersion`: the root barrel's `export *` eagerly loads every
|
|
2
|
+
// module including vue, which a host-side Node process (the daemon) cannot resolve. The root barrel still re-exports
|
|
3
|
+
// both names too.
|
|
16
4
|
|
|
17
5
|
export { satisfiesEngines } from "./engines.js";
|
|
18
6
|
export { extensionApiVersion } from "./version.js";
|
package/src/route.ts
CHANGED
|
@@ -1,17 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* A view's internal navigation lives in the QUERY because the path is already spoken for: `/ext/:ext/:key?` has
|
|
5
|
-
* exactly one free segment and it means "which activation". So several views can be addressing the same query
|
|
6
|
-
* string at once, and the one rule that matters is that none of them may clobber another's key. */
|
|
1
|
+
// Pure functions behind `api.route`'s URL-query rules; the host owns the router, this owns the rule. Navigation lives
|
|
2
|
+
// in the query since `/ext/:ext/:key?` has only one free segment (the activation); no view may clobber another's key.
|
|
7
3
|
|
|
8
4
|
// What the router hands back for a query: vue-router yields `string | null` per key, or an array when a key repeats.
|
|
9
5
|
export type RawQuery = Readonly<Record<string, string | readonly (string | null)[] | null | undefined>>;
|
|
10
6
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
* A valueless key (`?draft`) reads as the empty string, which is falsy-ish for the caller to test but never
|
|
14
|
-
* `undefined`, absent and present-but-empty are different answers. */
|
|
7
|
+
// Flattens a router query to a scalar record. A repeated key keeps its first value (what a hand-written or shared link
|
|
8
|
+
// means); a valueless key (`?draft`) becomes `""`, never `undefined`.
|
|
15
9
|
export const flattenQuery = (query: RawQuery): Record<string, string> => {
|
|
16
10
|
const out: Record<string, string> = {};
|
|
17
11
|
for (const [key, value] of Object.entries(query)) {
|
|
@@ -25,10 +19,8 @@ export const flattenQuery = (query: RawQuery): Record<string, string> => {
|
|
|
25
19
|
return out;
|
|
26
20
|
};
|
|
27
21
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
* construct. Every key the patch does not mention is carried through untouched, which is the whole invariant:
|
|
31
|
-
* a documentation view setting `doc` must not drop the terminal's or another view's parameters. */
|
|
22
|
+
// Merges a patch into the live query; `undefined` removes that key rather than leaving `?doc=` behind. Every key the
|
|
23
|
+
// patch doesn't mention passes through untouched.
|
|
32
24
|
export const mergeQuery = (current: RawQuery, patch: Readonly<Record<string, string | undefined>>): Record<string, string> => {
|
|
33
25
|
const next = flattenQuery(current);
|
|
34
26
|
for (const [key, value] of Object.entries(patch)) {
|
package/src/scope.ts
CHANGED
|
@@ -1,27 +1,7 @@
|
|
|
1
1
|
import { ref, type Ref } from "vue";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* Three tiers of client state exist in this app, and each needs a different answer to "what happens on a
|
|
6
|
-
* switch". Cached server state is keyed by `api.sandbox.key(...)`, so it is answered by construction. State
|
|
7
|
-
* inside a mounted component dies with the component. THIS is the third tier: module state owned by
|
|
8
|
-
* `activate()`, the badge counts, the document-presence maps, the poll results that must survive the view
|
|
9
|
-
* being unmounted, because a badge you only see once you have already navigated to the view is pointless.
|
|
10
|
-
*
|
|
11
|
-
* Nothing owned it. A rail tile filled by a ten-minute timer therefore kept the PREVIOUS sandbox's number for
|
|
12
|
-
* up to ten minutes after a switch, under the new sandbox's name, a badge is a claim addressed to the reader,
|
|
13
|
-
* and one describing a workspace they are no longer looking at is worse than no badge at all. It was not one
|
|
14
|
-
* extension's mistake either: every extension that badges had the identical shape, which is the signature of a
|
|
15
|
-
* missing primitive rather than of carelessness.
|
|
16
|
-
*
|
|
17
|
-
* So the host owns it. Declare the state through `sandboxRef` and the host empties it on every switch; there
|
|
18
|
-
* is no subscription to remember and no teardown to write.
|
|
19
|
-
*
|
|
20
|
-
* A MODULE-LEVEL REGISTRY IS CORRECT HERE, and that is worth saying because `hostSlot` in this same package
|
|
21
|
-
* warns against exactly that. The shell publishes ONE instance of this module to every bundle
|
|
22
|
-
* (extension-host/hostModules.ts), so a slot held here is shared by all of them, which made it wrong for a
|
|
23
|
-
* per-extension host handle and makes it right for this: one switch empties every extension's scope, and no
|
|
24
|
-
* extension can be missed. */
|
|
3
|
+
// Module state scoped to one sandbox (badge counts, poll results) that must survive a component unmount. Declare it
|
|
4
|
+
// through `sandboxRef`; the host clears it on every sandbox switch, so there is no subscription or teardown to write.
|
|
25
5
|
|
|
26
6
|
interface Registered {
|
|
27
7
|
readonly clear: () => void;
|
|
@@ -29,22 +9,11 @@ interface Registered {
|
|
|
29
9
|
|
|
30
10
|
const registered: Registered[] = [];
|
|
31
11
|
|
|
32
|
-
|
|
33
|
-
* does not need to. All any caller asks is "is this still the scope I started in", and a counter answers that
|
|
34
|
-
* without this package having to know what a sandbox is. */
|
|
12
|
+
// Sandbox scope counter; this package can't see sandbox ids, only whether the scope changed since a value was captured.
|
|
35
13
|
let generation = 0;
|
|
36
14
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*
|
|
40
|
-
* const unseen = sandboxRef<readonly ChoreVerdict[]>(() => []);
|
|
41
|
-
*
|
|
42
|
-
* It is an ordinary `Ref` in every other respect: read it in a `badge()` or a `detect()` and the host's own
|
|
43
|
-
* computed re-renders the tile when it changes, exactly as before. READ, not write, see `sandboxValue`.
|
|
44
|
-
*
|
|
45
|
-
* `dispose` is for state that owns something the garbage collector will not take back, an object URL, a
|
|
46
|
-
* subscription. It is handed the value being dropped, once, at the moment the scope closes. Most callers need
|
|
47
|
-
* none: a list of verdicts is released by being replaced. */
|
|
15
|
+
// Module state scoped to one sandbox: resets to a fresh `initial()` on every switch. `dispose`, if given, runs once on
|
|
16
|
+
// the outgoing value, for state that owns something the garbage collector won't reclaim.
|
|
48
17
|
export const sandboxRef = <T>(initial: () => T, dispose?: (previous: T) => void): Ref<T> => {
|
|
49
18
|
const state = ref(initial()) as Ref<T>;
|
|
50
19
|
registered.push({
|
|
@@ -56,30 +25,14 @@ export const sandboxRef = <T>(initial: () => T, dispose?: (previous: T) => void)
|
|
|
56
25
|
return state;
|
|
57
26
|
};
|
|
58
27
|
|
|
59
|
-
// A sandbox-scoped box that nothing observes
|
|
60
|
-
//
|
|
28
|
+
// A sandbox-scoped box that nothing observes; same `.value` shape as a `Ref` so state can move between the two without
|
|
29
|
+
// changing call sites.
|
|
61
30
|
export interface SandboxValue<T> {
|
|
62
31
|
value: T;
|
|
63
32
|
}
|
|
64
33
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
* fetch resumes from, the id a retry belongs to.
|
|
68
|
-
*
|
|
69
|
-
* It exists because of where `detect()` and `badge()` are called from. Both run INSIDE the host's render
|
|
70
|
-
* computed, that is the whole mechanism by which a tile repaints when a poll lands, and a `Ref` WRITTEN from
|
|
71
|
-
* inside a computed is that computed mutating its own dependency. Vue re-runs it, the write happens again, and
|
|
72
|
-
* the rail recurses until Vue abandons the flush mid-frame. What the reader sees then is not one broken tile:
|
|
73
|
-
* every update queued behind the rail is dropped with the flush, so the whole window stops answering, and the
|
|
74
|
-
* console fills with a recursion error naming a component that is merely where the loop was noticed.
|
|
75
|
-
*
|
|
76
|
-
* So the division is by AUDIENCE, not by lifetime: `sandboxRef` for what a tile SHOWS, `sandboxValue` for what
|
|
77
|
-
* a poll REMEMBERS. Both are emptied on a switch by the same door, and writing this one from a render callback
|
|
78
|
-
* is safe precisely because there is nothing to invalidate. When a poll's own bookkeeping later turns out to
|
|
79
|
-
* be worth showing, promoting it is a one-word change, and the promotion is the moment to check that nothing
|
|
80
|
-
* writes it from `detect()`.
|
|
81
|
-
*
|
|
82
|
-
* `dispose` behaves exactly as it does on `sandboxRef`. */
|
|
34
|
+
// Like `sandboxRef` but not reactive: safe to write from inside `detect()`/`badge()`, which run inside the host's own
|
|
35
|
+
// render computed, where writing a `Ref` would re-trigger it recursively.
|
|
83
36
|
export const sandboxValue = <T>(initial: () => T, dispose?: (previous: T) => void): SandboxValue<T> => {
|
|
84
37
|
const box: SandboxValue<T> = { value: initial() };
|
|
85
38
|
registered.push({
|
|
@@ -91,28 +44,14 @@ export const sandboxValue = <T>(initial: () => T, dispose?: (previous: T) => voi
|
|
|
91
44
|
return box;
|
|
92
45
|
};
|
|
93
46
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
* Emptying the refs is not enough on its own. A poll that issued its request under the old sandbox resolves a
|
|
97
|
-
* moment after the switch, and writes the old box's answer into the fresh scope, the same wrong badge, just
|
|
98
|
-
* harder to reproduce. There is no way for this module to cancel that request, so it offers the one thing the
|
|
99
|
-
* caller needs instead: a way to ask, after the await, whether the answer is still wanted.
|
|
100
|
-
*
|
|
101
|
-
* Take it BEFORE the await, ask it AFTER:
|
|
102
|
-
*
|
|
103
|
-
* const current = sandboxScopeGuard();
|
|
104
|
-
* const report = await api.sandbox.fetch(query);
|
|
105
|
-
* if (!current()) return;
|
|
106
|
-
* unseen.value = assess(report);
|
|
107
|
-
*
|
|
108
|
-
* Two lines, and the failure it prevents is invisible without them. */
|
|
47
|
+
// Snapshot the current scope before an await; call the result after to check the scope hasn't switched meanwhile.
|
|
48
|
+
// Needed because emptying the refs doesn't stop an in-flight request from writing its answer into the new scope.
|
|
109
49
|
export const sandboxScopeGuard = (): (() => boolean) => {
|
|
110
50
|
const taken = generation;
|
|
111
51
|
return () => taken === generation;
|
|
112
52
|
};
|
|
113
53
|
|
|
114
|
-
|
|
115
|
-
* to reset and no business resetting each other's. */
|
|
54
|
+
// Called by the shell whenever the active sandbox changes; extensions never call this, they have nothing to reset.
|
|
116
55
|
export const resetSandboxScope = (): void => {
|
|
117
56
|
generation += 1;
|
|
118
57
|
for (const entry of registered) {
|