@50arms/platform-mcp 0.0.0-stage → 0.1.437
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/README.md +114 -2
- package/dist/access-request.js +101 -0
- package/dist/agent-gate.js +69 -0
- package/dist/board-id.js +27 -0
- package/dist/bounded-retry.js +25 -0
- package/dist/checkout-docs-root.js +54 -0
- package/dist/clear-control.js +77 -0
- package/dist/compaction-control.js +142 -0
- package/dist/credential.js +104 -0
- package/dist/danxbot-api-examples.js +97 -0
- package/dist/dashboard-identity.js +14 -0
- package/dist/design-screenshot.js +138 -0
- package/dist/digest-hold.js +67 -0
- package/dist/docs-tools.js +140 -0
- package/dist/durable-write.js +56 -0
- package/dist/entrypoint.js +35 -0
- package/dist/forbidden-hint.js +23 -0
- package/dist/guarded-calls.js +21 -0
- package/dist/handlers.js +385 -0
- package/dist/heartbeat-reporter.js +156 -0
- package/dist/http-client.js +190 -0
- package/dist/index.js +1128 -0
- package/dist/json-body.js +12 -0
- package/dist/key-revoked-halt.js +30 -0
- package/dist/lifecycle.js +113 -0
- package/dist/listen.js +999 -0
- package/dist/listener-reminder.js +119 -0
- package/dist/machine-daemon/autostart.js +160 -0
- package/dist/machine-daemon/beat.js +77 -0
- package/dist/machine-daemon/cli.js +42 -0
- package/dist/machine-daemon/install.js +168 -0
- package/dist/machine-daemon/state.js +64 -0
- package/dist/machine-daemon/version.js +13 -0
- package/dist/machine-daemon.js +553 -0
- package/dist/machine-toolkit.js +460 -0
- package/dist/mini-app-build.js +159 -0
- package/dist/mini-app-checkout.js +215 -0
- package/dist/mini-app-output.js +111 -0
- package/dist/mini-app-publish.js +154 -0
- package/dist/mini-app-restore.js +175 -0
- package/dist/one-line.js +25 -0
- package/dist/pacing-relay.js +16 -0
- package/dist/permission-request.js +134 -0
- package/dist/permission-watch.js +145 -0
- package/dist/plan-events-relay.js +572 -0
- package/dist/plan-events-tool.js +9 -0
- package/dist/plugin-publish.js +38 -0
- package/dist/plugin-screenshot.js +81 -0
- package/dist/request-bound.js +21 -0
- package/dist/restart-notice.js +271 -0
- package/dist/result-limit.js +31 -0
- package/dist/role-instruction-file.js +36 -0
- package/dist/route-board-scope.generated.js +95 -0
- package/dist/route-board-scope.js +71 -0
- package/dist/session-access.js +322 -0
- package/dist/session-connection.js +264 -0
- package/dist/session-control-file.js +58 -0
- package/dist/session-key.js +125 -0
- package/dist/session-options.js +60 -0
- package/dist/session-stats.js +235 -0
- package/dist/session-stream.js +345 -0
- package/dist/session-title.js +129 -0
- package/dist/shutdown.js +38 -0
- package/dist/stop-fixes.js +50 -0
- package/dist/subagents-live.js +162 -0
- package/dist/terms-halt.js +39 -0
- package/dist/text-limit.js +24 -0
- package/dist/tool-access.js +114 -0
- package/dist/tool-defs.js +58 -0
- package/dist/tool-surface.js +59 -0
- package/dist/transcript-lineage.js +105 -0
- package/dist/types.js +45 -0
- package/dist/urgent-records.js +47 -0
- package/dist/version-floor.js +146 -0
- package/package.json +51 -4
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4233 - the IN-PROCESS relay of a session's plan events: what the `plan_events_wait` tool serves.
|
|
3
|
+
*
|
|
4
|
+
* WHO CALLS IT. In a person's session, the 50arms plugin's relay loop calls `plan_events_wait` over and over on this
|
|
5
|
+
* session's own platform MCP server and hands every record it gets to the session. In an orchestrator's dispatch
|
|
6
|
+
* (DX-4667), which loads no relay, a background waiter sub-agent calls it the same way and returns the records and the last
|
|
7
|
+
* cursor to the orchestrator. This class is everything on the
|
|
8
|
+
* server side of that call: it opens the session's dashboard event stream (the mint / stream / heartbeat contract of
|
|
9
|
+
* `session-stream.ts`), buffers records for the caller and reports the session's listener health. There is no process to start,
|
|
10
|
+
* watch or kill: the stream lives exactly as long as the MCP server, which lives exactly as long as its session.
|
|
11
|
+
*
|
|
12
|
+
* THE WAIT CONTRACT (`wait`). One rule, one reset, and a cursor that is opaque to the caller.
|
|
13
|
+
* 1. ONE RULE, BY ARRIVAL. Every record (a dashboard event, a digest, a notice, a pacing or resume message) is given the next
|
|
14
|
+
* number of a sequence that only ever increases: its `seq`. Each answered record carries a CURSOR, an opaque string. The caller
|
|
15
|
+
* sends back, on its next call, the cursor of the newest record it has TAKEN (not necessarily delivered; DX-5403 / DX-5398). A call
|
|
16
|
+
* answers every buffered record whose seq is above the cursor's (at once when there is one, else when one arrives, else empty
|
|
17
|
+
* at the timeout) and DROPS every record at or below it, at the call (`acknowledged`, the one predicate). An answer is never a
|
|
18
|
+
* drain: a record stays buffered until a LATER call's cursor passes it, but the server does NOT promise to keep a record the
|
|
19
|
+
* caller has already taken. The 50arms plugin keeps its taken-but-undelivered events in its own `relayOutbox` $.state atom, so
|
|
20
|
+
* a reload of its hook module loses none (it may repeat one); a new process with no atom resumes from the plugin's stored
|
|
21
|
+
* delivered cursor.
|
|
22
|
+
* The dashboard numbers its events when it INSERTS them but publishes them when it COMMITS, so an event can arrive after
|
|
23
|
+
* a higher-numbered one; arrival order is the only order the relay can promise, which is why the dashboard's id is never
|
|
24
|
+
* what is compared.
|
|
25
|
+
* 2. THE CURSOR. `<seq>:<id>,<id>,...`: the record's seq, then the dashboard event ids handed out up to and including it, in
|
|
26
|
+
* arrival order and bounded to the last `CURSOR_IDS_MAX` (`formatCursor`, `parseCursor`: the seq and every id are base-10
|
|
27
|
+
* whole numbers with no leading zero, the ids are distinct and positive; anything else is refused loudly). The ids are what
|
|
28
|
+
* makes a restart exact. After a restart of the MCP server (a resumed session) a fresh relay is presented its caller's
|
|
29
|
+
* cursor: its sequence continues from that seq, and the stream resumes after the highest of those ids with the listener's
|
|
30
|
+
* duplicate guard seeded with all of them. The dashboard replays every event created within its 30 s commit-order overlap
|
|
31
|
+
* (`REPLAY_COMMIT_ORDER_OVERLAP_MS`) before the last one seen and relies on that guard, so an already delivered event in
|
|
32
|
+
* the overlap is dropped and one never delivered is delivered.
|
|
33
|
+
* THE ACCEPTED GAP: an insert that commits more than that 30 s overlap after the event it follows is missed by a RESTART
|
|
34
|
+
* resume (the dashboard's own limit); a running relay never misses one. And a burst of more than `CURSOR_IDS_MAX` events
|
|
35
|
+
* inside the overlap can replay the oldest of them once (a duplicate, never a loss).
|
|
36
|
+
* 3. ONE PLAN. A call names the plan the session is on (`planId`), or `null` for a signed-in session on no plan (DX-4882: its stream
|
|
37
|
+
* still carries the pacing and resume messages of its account). A plan different from the last call's, to or from `null` included,
|
|
38
|
+
* drops the PLAN-scoped records buffered or held for the plan it left, so a move starts clean. SESSION-scoped records (pacing, resume
|
|
39
|
+
* and the relay's own failure and revoked-key notices: they say nothing about a plan, and the dashboard never replays
|
|
40
|
+
* them) are kept, here and across the 40 s suspend below. The ids of a dropped record leave the cursors of the records that
|
|
41
|
+
* stay: delivering a kept record must not claim an event that was dropped undelivered. The stream stays on a move: it
|
|
42
|
+
* belongs to the SESSION, and the dashboard delivers an event only while the session is on the event's plan.
|
|
43
|
+
*
|
|
44
|
+
* One wait is pending at a time: a newer call supersedes the older, which answers empty at once. A call whose request is
|
|
45
|
+
* aborted drops its waiter. A terminal outcome answers `{stopped}` with the remedy (`stop-fixes.ts`); the call after it tries
|
|
46
|
+
* again from scratch.
|
|
47
|
+
*
|
|
48
|
+
* WHO IS HEALTHY. The relay heartbeats every `HEARTBEAT_INTERVAL_MS` while a call is pending or one was made within
|
|
49
|
+
* `HEARTBEAT_ACTIVITY_WINDOW_MS`. After that window the stream is closed and the heartbeat stops, so the dashboard reads the
|
|
50
|
+
* session not healthy when nothing is reading its events (a dead or unloaded plugin module). The window is 40 s: a pending
|
|
51
|
+
* call counts, so it only has to outlast the caller's turnaround and failure backoff (the plugin waits 20 s and backs off at
|
|
52
|
+
* most 10 s), and it must stay under the dashboard's liveness window (`SESSION_LIVENESS_WINDOW_MS`, 60 s) minus one beat. The
|
|
53
|
+
* sequence and the highest event id survive the gap, so a cursor stays valid across it.
|
|
54
|
+
*
|
|
55
|
+
* NOTHING OUTLIVES THE SESSION. The heartbeat interval, the backoff sleep and the digest hold are unref'd, the stream and the
|
|
56
|
+
* mint / inventory requests are aborted by `close()`, and `close()` also clears a pending call's wait timer (the one ref'd timer,
|
|
57
|
+
* held only until the call answers). The server calls `close()` when its transport closes and when its stdin ends (`shutdown.ts`).
|
|
58
|
+
*/
|
|
59
|
+
import { fileOf, formatCompactionAskLine } from "./compaction-control.js";
|
|
60
|
+
import { clearFileOf, formatClearAskLine } from "./clear-control.js";
|
|
61
|
+
import { DigestHold } from "./digest-hold.js";
|
|
62
|
+
import { HeartbeatReporter } from "./heartbeat-reporter.js";
|
|
63
|
+
import { capResumeIds, DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, INITIAL_BACKOFF_MS, MAX_BACKOFF_MS, runListener, } from "./listen.js";
|
|
64
|
+
import { fetchPlanInventory, HEARTBEAT_INTERVAL_MS, heartbeatConnection, MAX_REFUSED_TICKETS_IN_A_ROW, mintTicket, } from "./session-stream.js";
|
|
65
|
+
import { fixForStop } from "./stop-fixes.js";
|
|
66
|
+
import { LINE_PREFIX } from "./one-line.js";
|
|
67
|
+
import { isUrgent } from "./urgent-records.js";
|
|
68
|
+
/** The shortest and longest wait one call may ask for; a short ceiling keeps an orphaned waiter's life short. */
|
|
69
|
+
export const WAIT_MIN_MS = 1_000;
|
|
70
|
+
export const WAIT_MAX_MS = 30_000;
|
|
71
|
+
/** How long after a call (or the end of one) the relay keeps heartbeating and streaming; see the module docblock. */
|
|
72
|
+
export const HEARTBEAT_ACTIVITY_WINDOW_MS = 40_000;
|
|
73
|
+
/**
|
|
74
|
+
* How many delivered dashboard event ids a cursor carries. The dashboard replays the events created within its 30 s overlap
|
|
75
|
+
* before the last one seen: 64 covers a busy plan's burst in that window many times over while keeping every record's cursor
|
|
76
|
+
* a few hundred characters. (The listener's own memory, `DELIVERED_ID_MEMORY`, is far larger and is not what a cursor carries.)
|
|
77
|
+
*/
|
|
78
|
+
export const CURSOR_IDS_MAX = 64;
|
|
79
|
+
const WHOLE = "(?:0|[1-9][0-9]*)";
|
|
80
|
+
const CURSOR_PATTERN = new RegExp(`^(${WHOLE}):((?:[1-9][0-9]*)(?:,[1-9][0-9]*)*)?$`);
|
|
81
|
+
export const formatCursor = (cursor) => `${cursor.seq}:${cursor.ids.join(",")}`;
|
|
82
|
+
/** Reads a cursor this relay issued. Anything else is refused loudly: a guess would hide a lost or repeated record. */
|
|
83
|
+
export function parseCursor(raw) {
|
|
84
|
+
const match = CURSOR_PATTERN.exec(raw);
|
|
85
|
+
const seq = match === null ? NaN : Number(match[1]);
|
|
86
|
+
const ids = match === null || match[2] === undefined ? [] : match[2].split(",").map(Number);
|
|
87
|
+
const valid = Number.isSafeInteger(seq) && ids.length <= CURSOR_IDS_MAX && ids.every(Number.isSafeInteger) && new Set(ids).size === ids.length;
|
|
88
|
+
if (!valid) {
|
|
89
|
+
throw new Error(`plan_events_wait: cursor ${JSON.stringify(raw)} is not one this server issued (expected "<seq>:<id>,<id>,...": a whole number, a colon, then up to ${CURSOR_IDS_MAX} distinct positive event ids)`);
|
|
90
|
+
}
|
|
91
|
+
return { seq, ids };
|
|
92
|
+
}
|
|
93
|
+
const boundedIds = (ids) => [...new Set(ids)].slice(-CURSOR_IDS_MAX);
|
|
94
|
+
/** DX-4233: the one rule of the cursor: a record at or below the presented cursor's seq has been taken by the caller, so the server drops it. */
|
|
95
|
+
export const acknowledged = (seq, cursor) => cursor !== null && seq <= cursor.seq;
|
|
96
|
+
export class PlanEventRelay {
|
|
97
|
+
deps;
|
|
98
|
+
buffer = [];
|
|
99
|
+
waiter = null;
|
|
100
|
+
run = null;
|
|
101
|
+
/** The plan the last call named (`null` = a session on no plan); `undefined` before the first call, which has no plan to have moved from. */
|
|
102
|
+
lastPlanId = undefined;
|
|
103
|
+
/** The last seq given: it continues from a presented cursor and survives a suspend, so a cursor stays valid across the gap. */
|
|
104
|
+
seq = 0;
|
|
105
|
+
/** The dashboard event ids that arrived (or were presented in a cursor), oldest first, bounded: the next record's `recent`. */
|
|
106
|
+
recentIds = [];
|
|
107
|
+
/** A terminal outcome nobody was waiting for: answered by the next call. */
|
|
108
|
+
pendingStop = null;
|
|
109
|
+
/** Dashboard ids already given to the buffer (and the cursor the run started from): handed to a re-mint so a replay is dropped. */
|
|
110
|
+
seenIds = [];
|
|
111
|
+
connectionState = "reconnecting";
|
|
112
|
+
stoppedReason = null;
|
|
113
|
+
lastInventoryError = null;
|
|
114
|
+
/** DX-4882: whether the connected plan's inventory has been read in this run (a session on no plan has none to read). */
|
|
115
|
+
inventoryRead = false;
|
|
116
|
+
lastActivityAt = 0;
|
|
117
|
+
heartbeat = null;
|
|
118
|
+
reporter = null;
|
|
119
|
+
/** DX-5415: the chain of every reporter stop still storing held finals (a suspend's and a transcript switch's); null when none is pending. Never rejects (see `chainSettling`). */
|
|
120
|
+
settling = null;
|
|
121
|
+
/** DX-5415: set by `close()` (the server's shutdown) and never cleared: a call paused across the close, or made after it, answers empty and starts nothing. */
|
|
122
|
+
closed = false;
|
|
123
|
+
digest = new DigestHold((event) => this.accept(event));
|
|
124
|
+
constructor(deps) {
|
|
125
|
+
this.deps = deps;
|
|
126
|
+
}
|
|
127
|
+
/** The one entry point: see the module docblock for the contract. */
|
|
128
|
+
async wait(args, signal) {
|
|
129
|
+
// Parsed first: a cursor this server did not issue is refused before it changes anything.
|
|
130
|
+
const cursor = args.cursor === null ? null : parseCursor(args.cursor);
|
|
131
|
+
// DX-4233: a request already cancelled (a plugin reload) touches nothing and starts nothing.
|
|
132
|
+
if (signal?.aborted || this.closed)
|
|
133
|
+
return { events: [] };
|
|
134
|
+
// DX-5415: a suspend still storing the old collector's held finals ends before this call starts a new one (bounded inside).
|
|
135
|
+
if (this.settling !== null) {
|
|
136
|
+
await this.settling;
|
|
137
|
+
if (signal?.aborted || this.closed)
|
|
138
|
+
return { events: [] };
|
|
139
|
+
}
|
|
140
|
+
// DX-4233: one wait at a time; the older call answers empty so its caller is never left hanging.
|
|
141
|
+
this.supersedeWaiter();
|
|
142
|
+
// DX-4233: a plan move starts clean (the very first call has no last plan; after a suspend the last plan is still known, and `dropPlanScoped` already ran).
|
|
143
|
+
if (this.lastPlanId !== undefined && args.planId !== this.lastPlanId) {
|
|
144
|
+
this.startClean();
|
|
145
|
+
this.rereadInventory(args.planId);
|
|
146
|
+
}
|
|
147
|
+
this.lastPlanId = args.planId;
|
|
148
|
+
if (cursor !== null) {
|
|
149
|
+
// DX-4233: a fresh relay (a restarted server) continues the caller's sequence and remembers the ids it was handed; a
|
|
150
|
+
// running one is already past both.
|
|
151
|
+
this.seq = Math.max(this.seq, cursor.seq); // DX-4233: max, never `=`: a stale cursor (a plugin reload) must not reuse a buffered seq
|
|
152
|
+
this.recentIds = boundedIds([...cursor.ids, ...this.recentIds]);
|
|
153
|
+
// What the caller delivered precedes every record still buffered, so each of their cursors carries it (a dropped
|
|
154
|
+
// plan-scoped record's ids were taken out of them, and the caller's own delivered ids go back in).
|
|
155
|
+
this.buffer = this.buffer.map((r) => ({ ...r, recent: boundedIds([...cursor.ids, ...r.recent]) }));
|
|
156
|
+
}
|
|
157
|
+
this.touch();
|
|
158
|
+
this.ensureReporter(args.transcriptPath);
|
|
159
|
+
// DX-5415 / DX-4233: a transcript switch's settle rides `settling` (the chain a suspend uses), so every call paused on it resumes in call order and the older one cannot overwrite the newer call's waiter.
|
|
160
|
+
if (this.settling !== null) {
|
|
161
|
+
await this.settling;
|
|
162
|
+
// The pause let a close() or a cancel through; a close already let go of the stream, heartbeat and reporter, so none is started again.
|
|
163
|
+
if (signal?.aborted || this.closed)
|
|
164
|
+
return { events: [] };
|
|
165
|
+
}
|
|
166
|
+
this.buffer = this.buffer.filter((r) => !acknowledged(r.seq, cursor));
|
|
167
|
+
if (this.pendingStop !== null) {
|
|
168
|
+
const stopped = this.pendingStop;
|
|
169
|
+
this.pendingStop = null;
|
|
170
|
+
return { stopped };
|
|
171
|
+
}
|
|
172
|
+
// DX-4233: a server holding no key answers a stop without calling the dashboard (a dispatch always holds its env key, so it never takes this branch).
|
|
173
|
+
if (this.deps.auth() === null) {
|
|
174
|
+
return { stopped: this.stopRecord("credential_unavailable", "this session holds no dashboard key") };
|
|
175
|
+
}
|
|
176
|
+
if (this.run === null)
|
|
177
|
+
this.start(cursor);
|
|
178
|
+
if (this.buffer.length > 0)
|
|
179
|
+
return this.answer();
|
|
180
|
+
// DX-5415 / DX-4233: supersede where the waiter is installed, not only before the pauses, so an older call that resumed late cannot orphan the waiter beneath it. Calls resume in call order because a settle (capped at FINAL_SETTLE_BOUND_MS) ends before the idle window (HEARTBEAT_ACTIVITY_WINDOW_MS) can chain a second one; a wall-clock jump can break that order, costing the newer caller one early empty answer, never a second waiter.
|
|
181
|
+
this.supersedeWaiter();
|
|
182
|
+
return new Promise((resolve) => {
|
|
183
|
+
const onAbort = () => this.supersedeWaiter();
|
|
184
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
185
|
+
const waiter = {
|
|
186
|
+
settle: resolve,
|
|
187
|
+
timer: setTimeout(() => {
|
|
188
|
+
this.waiter = null;
|
|
189
|
+
this.finishCall(waiter, { events: [] });
|
|
190
|
+
}, args.timeoutMs),
|
|
191
|
+
detach: () => signal?.removeEventListener("abort", onAbort),
|
|
192
|
+
};
|
|
193
|
+
this.waiter = waiter;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/** Stop everything: the pending call, the stream, the heartbeat, the transcript tailer. For the server's shutdown. */
|
|
197
|
+
close() {
|
|
198
|
+
this.closed = true;
|
|
199
|
+
this.supersedeWaiter();
|
|
200
|
+
return this.suspend();
|
|
201
|
+
}
|
|
202
|
+
// ---------------------------------------------------------------------------------------------------------------- calls
|
|
203
|
+
finishCall(waiter, answer) {
|
|
204
|
+
clearTimeout(waiter.timer);
|
|
205
|
+
waiter.detach();
|
|
206
|
+
this.lastActivityAt = this.deps.now();
|
|
207
|
+
waiter.settle(answer);
|
|
208
|
+
}
|
|
209
|
+
/** The pending call, if any, answers empty at once (a newer call replaced it, or its request went away). */
|
|
210
|
+
supersedeWaiter() {
|
|
211
|
+
const waiter = this.waiter;
|
|
212
|
+
if (waiter === null)
|
|
213
|
+
return;
|
|
214
|
+
this.waiter = null;
|
|
215
|
+
this.finishCall(waiter, { events: [] });
|
|
216
|
+
}
|
|
217
|
+
/** Everything buffered, each record with the cursor to store once it is delivered. */
|
|
218
|
+
answer() {
|
|
219
|
+
return { events: this.buffer.map(({ seq, recent, text, urgent, acks }) => ({ cursor: formatCursor({ seq, ids: recent }), text, urgent, acks })) };
|
|
220
|
+
}
|
|
221
|
+
// ------------------------------------------------------------------------------------------------------------ the buffer
|
|
222
|
+
/**
|
|
223
|
+
* A record the session should now be told: given the next seq, buffered for the caller, and the pending call answers with it.
|
|
224
|
+
* A replayed dashboard event never reaches here twice: the listener's duplicate guard (seeded from the cursor) drops it first.
|
|
225
|
+
*/
|
|
226
|
+
accept(event) {
|
|
227
|
+
// DX-4233: the seq is given at arrival and never reused, so a record the dashboard publishes late is just the next one.
|
|
228
|
+
const own = event.ids ?? (event.id === null ? [] : [event.id]);
|
|
229
|
+
this.recentIds = boundedIds([...this.recentIds, ...own]);
|
|
230
|
+
for (const id of own) {
|
|
231
|
+
this.seenIds.push(id);
|
|
232
|
+
if (this.seenIds.length > DELIVERED_ID_MEMORY)
|
|
233
|
+
this.seenIds.shift();
|
|
234
|
+
}
|
|
235
|
+
this.seq += 1;
|
|
236
|
+
this.buffer.push({ seq: this.seq, own, recent: [...this.recentIds], text: event.text, scope: event.scope ?? "plan", urgent: event.urgent, acks: event.acks });
|
|
237
|
+
const waiter = this.waiter;
|
|
238
|
+
if (waiter === null)
|
|
239
|
+
return;
|
|
240
|
+
this.waiter = null;
|
|
241
|
+
this.finishCall(waiter, this.answer());
|
|
242
|
+
}
|
|
243
|
+
// ---------------------------------------------------------------------------------------------------- lifecycle and health
|
|
244
|
+
/**
|
|
245
|
+
* DX-4233: drop the plan-scoped records, keeping the session-scoped ones. The ids of what is dropped leave the cursors of what
|
|
246
|
+
* stays (and the remembered ids): delivering a kept record must not claim an event that was dropped undelivered, or a
|
|
247
|
+
* restart would resume past it and the dashboard would never replay it.
|
|
248
|
+
*/
|
|
249
|
+
dropPlanScoped() {
|
|
250
|
+
const dropped = new Set(this.buffer.filter((r) => r.scope === "plan").flatMap((r) => r.own));
|
|
251
|
+
this.buffer = this.buffer
|
|
252
|
+
.filter((r) => r.scope === "session")
|
|
253
|
+
.map((r) => ({ ...r, recent: r.recent.filter((id) => !dropped.has(id)) }));
|
|
254
|
+
this.recentIds = this.recentIds.filter((id) => !dropped.has(id));
|
|
255
|
+
// DX-4233: `seenIds` (the listener's duplicate guard for a re-mint on the SAME run) deliberately keeps them. A plan move's
|
|
256
|
+
// dropped events belong to a plan the session left, and the dashboard replays only the session's current plan, so nothing
|
|
257
|
+
// legitimate is replayed into the guard; and the gap, which does need them replayed, ends the run, so `start` builds a fresh `seenIds` from the next call's cursor.
|
|
258
|
+
}
|
|
259
|
+
/** Whether the last call named a plan (not `null` = no plan, not `undefined` = no call yet). */
|
|
260
|
+
onAPlan() {
|
|
261
|
+
return typeof this.lastPlanId === "number";
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* DX-4882 / DX-6157: the inventory read no longer describes the session: the plan changed, or a person's Create gave the session an agent (and a plan) after a read refused 403
|
|
265
|
+
* agent_needed. On no plan there is nothing to read (and nothing to report as unavailable); on a plan, a live run reads it now (a run that is not live reads it when it starts).
|
|
266
|
+
*/
|
|
267
|
+
rereadInventory(planId) {
|
|
268
|
+
this.inventoryRead = false;
|
|
269
|
+
this.lastInventoryError = null;
|
|
270
|
+
const run = this.run;
|
|
271
|
+
const auth = this.deps.auth();
|
|
272
|
+
if (planId === null || run === null || auth === null)
|
|
273
|
+
return;
|
|
274
|
+
void this.readInventory(run, auth, { ...this.deps, signal: run.abort.signal });
|
|
275
|
+
}
|
|
276
|
+
/** The inventory is a property of the credential and the plan: read best effort, never a gate; a failed read is reported on the heartbeat (`inventory: unavailable`) and tried again at the next re-mint. */
|
|
277
|
+
async readInventory(run, auth, http) {
|
|
278
|
+
const inventory = await fetchPlanInventory(auth, http);
|
|
279
|
+
if (!this.current(run))
|
|
280
|
+
return;
|
|
281
|
+
this.inventoryRead = inventory.kind === "ok";
|
|
282
|
+
this.lastInventoryError = inventory.kind === "ok" ? null : inventory.detail;
|
|
283
|
+
}
|
|
284
|
+
/** The session moved to another plan: drop what was buffered or held for the plan it left; session-scoped records stay. */
|
|
285
|
+
startClean() {
|
|
286
|
+
this.dropPlanScoped();
|
|
287
|
+
this.digest.reset();
|
|
288
|
+
}
|
|
289
|
+
/** Creates the heartbeat reporter on the first call; a later call's transcript path (a resumed session) is handed to it. */
|
|
290
|
+
ensureReporter(transcriptPath) {
|
|
291
|
+
if (this.reporter !== null) {
|
|
292
|
+
const switching = this.reporter.useTranscript(transcriptPath);
|
|
293
|
+
if (switching !== null)
|
|
294
|
+
this.chainSettling(switching);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
this.reporter = new HeartbeatReporter({
|
|
298
|
+
auth: () => {
|
|
299
|
+
const auth = this.deps.auth();
|
|
300
|
+
if (auth === null)
|
|
301
|
+
throw new Error("plan_events_wait: this server holds no dashboard key to heartbeat with");
|
|
302
|
+
return auth;
|
|
303
|
+
},
|
|
304
|
+
http: this.deps,
|
|
305
|
+
makeStatsWatcher: this.deps.makeStatsWatcher,
|
|
306
|
+
now: this.deps.now,
|
|
307
|
+
connection: () => heartbeatConnection(this.connectionState, this.stoppedReason, this.lastInventoryError),
|
|
308
|
+
streaming: () => this.connectionState === "streaming",
|
|
309
|
+
// `origin: null` skips the agent/machine hold: the notice reaches the session at once. It is about the session, not a plan.
|
|
310
|
+
announce: (text, source) => this.digest.route({ type: "event", id: null, text, origin: null, scope: "session", urgent: isUrgent({ source }), acks: [] }),
|
|
311
|
+
forceReconnect: () => this.run?.forceReconnect?.(),
|
|
312
|
+
}, transcriptPath ?? null);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* DX-5415: the one settle chain: the last settle and `stopping` both finish before it clears itself.
|
|
316
|
+
* `stopping` never rejects (a reporter's stop settles its own failures), so `settling` never rejects and
|
|
317
|
+
* every waiter on it resumes; do not add a swallowing fallback here.
|
|
318
|
+
*/
|
|
319
|
+
chainSettling(stopping) {
|
|
320
|
+
const settling = Promise.all([this.settling, stopping]).then(() => {
|
|
321
|
+
if (this.settling === settling)
|
|
322
|
+
this.settling = null;
|
|
323
|
+
});
|
|
324
|
+
this.settling = settling;
|
|
325
|
+
}
|
|
326
|
+
/** Mark the relay in use and make sure the heartbeat is scheduled. */
|
|
327
|
+
touch() {
|
|
328
|
+
this.lastActivityAt = this.deps.now();
|
|
329
|
+
this.heartbeat ??= this.deps.scheduleInterval(() => this.heartbeatTick(), HEARTBEAT_INTERVAL_MS);
|
|
330
|
+
}
|
|
331
|
+
heartbeatTick() {
|
|
332
|
+
// DX-4233: a pending call keeps the relay alive however long it waits; only a gap with no call at all suspends it.
|
|
333
|
+
if (this.waiter === null && this.deps.now() - this.lastActivityAt > HEARTBEAT_ACTIVITY_WINDOW_MS) {
|
|
334
|
+
// Nobody is reading this session's events any more: stop heartbeating (the dashboard then reads the listener not
|
|
335
|
+
// healthy) and let go of the stream. The next call starts it again from its cursor; the dashboard replays what it missed.
|
|
336
|
+
void this.suspend();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const auth = this.deps.auth();
|
|
340
|
+
if (auth === null)
|
|
341
|
+
return;
|
|
342
|
+
this.touchControlFiles(auth.sessionId);
|
|
343
|
+
this.reporter?.tick();
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* DX-4993 / DX-6692: a live relay keeps its session's control files (compaction, clear) out of the weekly pruning (`SessionControlFiles.touch`); a failure is reported to stderr, never
|
|
347
|
+
* ends the heartbeat.
|
|
348
|
+
*/
|
|
349
|
+
touchControlFiles(sessionId) {
|
|
350
|
+
for (const kind of [this.compactionKind(), this.clearKind()]) {
|
|
351
|
+
try {
|
|
352
|
+
kind.files.touch(sessionId);
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
process.stderr.write(`plan_events_wait: the ${kind.what} file could not be kept alive: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Let go of everything the relay holds for a caller: the stream, the heartbeat, the digest, and the plan-scoped records.
|
|
361
|
+
* What was held for a digest was never marked delivered, so the dashboard's replay (after the caller's cursor) returns it to
|
|
362
|
+
* whoever calls next. Session-scoped records stay (the dashboard never replays them), and so does the sequence, so a cursor
|
|
363
|
+
* stays valid across the gap; the delivered ids come back with the next call's cursor.
|
|
364
|
+
*/
|
|
365
|
+
suspend() {
|
|
366
|
+
this.heartbeat?.stop();
|
|
367
|
+
this.heartbeat = null;
|
|
368
|
+
this.run?.abort.abort();
|
|
369
|
+
this.run = null;
|
|
370
|
+
// DX-5415: the reporter's stop settles its collector's held sub-agent finals first; callers that can wait (shutdown, the next call) do.
|
|
371
|
+
const stopping = this.reporter?.stop() ?? null;
|
|
372
|
+
if (stopping !== null)
|
|
373
|
+
this.chainSettling(stopping);
|
|
374
|
+
this.reporter = null;
|
|
375
|
+
this.digest.reset();
|
|
376
|
+
this.dropPlanScoped();
|
|
377
|
+
// DX-4233: `dropPlanScoped` already took the undelivered records' ids out of `recentIds`, so the dashboard replays them; what
|
|
378
|
+
// stays there was delivered. `seenIds` is reassigned by `start`, and a plan move after the gap is dropped by `dropPlanScoped`
|
|
379
|
+
// above, so neither is reset here.
|
|
380
|
+
this.pendingStop = null;
|
|
381
|
+
this.connectionState = "reconnecting";
|
|
382
|
+
this.stoppedReason = null;
|
|
383
|
+
this.lastInventoryError = null;
|
|
384
|
+
return this.settling ?? Promise.resolve();
|
|
385
|
+
}
|
|
386
|
+
// ------------------------------------------------------------------------------------------------- the mint / stream loop
|
|
387
|
+
start(cursor) {
|
|
388
|
+
// The listener's duplicate guard starts with every event the caller was handed and everything still buffered: the dashboard
|
|
389
|
+
// replays its 30 s overlap before the highest of them, so a replayed one is dropped and a never-delivered one is delivered.
|
|
390
|
+
this.seenIds = capResumeIds([...(cursor?.ids ?? []), ...this.buffer.flatMap((r) => r.own)]);
|
|
391
|
+
this.stoppedReason = null;
|
|
392
|
+
this.inventoryRead = false;
|
|
393
|
+
const run = { abort: new AbortController(), forceReconnect: null };
|
|
394
|
+
this.run = run;
|
|
395
|
+
void this.loop(run).catch((err) => {
|
|
396
|
+
// A bug, not a stream outcome: say so out loud, and answer it like any terminal outcome rather than idle silently.
|
|
397
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
398
|
+
process.stderr.write(`plan_events_wait: the event relay failed: ${message}\n`);
|
|
399
|
+
this.terminal(run, "relay_failed", message);
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
/** DX-4993: the compaction control's file, ask line and fault wording. */
|
|
403
|
+
compactionKind() {
|
|
404
|
+
return { files: this.deps.compactionFiles, fileOf, askLine: formatCompactionAskLine, askSource: { source: "compaction_ask" }, what: "compaction control", faultEffect: "so its compaction gate cannot work" };
|
|
405
|
+
}
|
|
406
|
+
/** DX-6692: the clear control's file, ask line and fault wording. */
|
|
407
|
+
clearKind() {
|
|
408
|
+
return { files: this.deps.clearFiles, fileOf: clearFileOf, askLine: formatClearAskLine, askSource: { source: "clear_ask" }, what: "clear control", faultEffect: "so the ready loop cannot see an agreed clear" };
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* DX-4993 / DX-6692 - a control (compaction, clear) arrived: show the agent an ask it has not been shown, and write the file the plugin reads. A control with a new ask (`ask.at`) is
|
|
412
|
+
* shown once: the file remembers which, so a reconnect, a restart of this server or a repeat of the same frame shows nothing twice. A file that cannot be read is replaced and reported on
|
|
413
|
+
* stderr only (DX-6156); a file that cannot be written is a line to the agent (a fault: the plugin cannot act on the control), never silence.
|
|
414
|
+
*/
|
|
415
|
+
applyControl(control, kind) {
|
|
416
|
+
const auth = this.deps.auth();
|
|
417
|
+
if (auth === null)
|
|
418
|
+
return;
|
|
419
|
+
// DX-4993: a file that cannot be read is no memory of what was shown, never a reason not to write: the write below replaces it (otherwise the same fault repeats
|
|
420
|
+
// at every frame and the plugin never gets a readable control).
|
|
421
|
+
// DX-6156: a repaired file is internal only (one stderr line, after the write succeeds); the agent hears of it only through the fault line when the replacing write fails, below.
|
|
422
|
+
let askedAt = null;
|
|
423
|
+
let unreadable = null;
|
|
424
|
+
try {
|
|
425
|
+
askedAt = kind.files.read(auth.sessionId)?.askedAt ?? null;
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
unreadable = err instanceof Error ? err.message : String(err);
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
const ask = (control?.ask ?? null);
|
|
432
|
+
const show = ask !== null && ask.at !== askedAt;
|
|
433
|
+
kind.files.write(auth.sessionId, kind.fileOf(control, ask === null ? null : show ? ask.at : askedAt, auth.sessionId));
|
|
434
|
+
if (unreadable !== null)
|
|
435
|
+
process.stderr.write(`plan_events_wait: the ${kind.what} file of session ${auth.sessionId} could not be read (${unreadable}); it was replaced\n`);
|
|
436
|
+
if (show) {
|
|
437
|
+
this.digest.route({ type: "event", id: null, text: kind.askLine(ask), origin: null, urgent: isUrgent(kind.askSource), acks: [], scope: "session" });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
442
|
+
this.digest.route({ type: "event", id: null, text: `${LINE_PREFIX} the ${kind.what} could not be kept for this session (${detail}), ${kind.faultEffect}`, origin: null, urgent: isUrgent({ source: "fault" }), acks: [], scope: "session" });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
current(run) {
|
|
446
|
+
return this.run === run && !run.abort.signal.aborted;
|
|
447
|
+
}
|
|
448
|
+
stopRecord(reason, detail) {
|
|
449
|
+
return { reason, detail, fix: fixForStop(reason) };
|
|
450
|
+
}
|
|
451
|
+
/** A terminal outcome: the pending call (or else the next) answers `{stopped}`; the loop ends. */
|
|
452
|
+
terminal(run, reason, detail) {
|
|
453
|
+
// DX-4233: only the run that is still current may end the relay; a superseded or closed run ending late is ignored.
|
|
454
|
+
if (!this.current(run))
|
|
455
|
+
return;
|
|
456
|
+
this.digest.cancel();
|
|
457
|
+
this.run = null;
|
|
458
|
+
this.connectionState = "stopped";
|
|
459
|
+
this.stoppedReason = reason;
|
|
460
|
+
const stopped = this.stopRecord(reason, detail);
|
|
461
|
+
const waiter = this.waiter;
|
|
462
|
+
if (waiter === null) {
|
|
463
|
+
this.pendingStop = stopped;
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
this.waiter = null;
|
|
467
|
+
this.finishCall(waiter, { stopped });
|
|
468
|
+
}
|
|
469
|
+
async loop(run) {
|
|
470
|
+
const sleep = (ms) => this.deps.sleep(ms, run.abort.signal);
|
|
471
|
+
// The mint and the inventory read end with the run, so a closed relay leaves no request in flight.
|
|
472
|
+
const http = { ...this.deps, signal: run.abort.signal };
|
|
473
|
+
let backoff = INITIAL_BACKOFF_MS;
|
|
474
|
+
let refusedInRow = 0;
|
|
475
|
+
for (;;) {
|
|
476
|
+
if (!this.current(run))
|
|
477
|
+
return;
|
|
478
|
+
this.connectionState = "reconnecting";
|
|
479
|
+
this.reporter?.resetFailureStreak();
|
|
480
|
+
run.forceReconnect = null;
|
|
481
|
+
const auth = this.deps.auth();
|
|
482
|
+
if (auth === null)
|
|
483
|
+
return this.terminal(run, "credential_unavailable", "this session holds no dashboard key");
|
|
484
|
+
const minted = await mintTicket(auth, http);
|
|
485
|
+
if (!this.current(run))
|
|
486
|
+
return;
|
|
487
|
+
if (minted.kind === "terminal")
|
|
488
|
+
return this.terminal(run, minted.reason, minted.detail);
|
|
489
|
+
if (minted.kind === "transient") {
|
|
490
|
+
await sleep(backoff);
|
|
491
|
+
backoff = Math.min(MAX_BACKOFF_MS, backoff * 2);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
// DX-4882: only a session on a plan has an inventory to read.
|
|
495
|
+
if (!this.inventoryRead && this.onAPlan()) {
|
|
496
|
+
await this.readInventory(run, auth, http);
|
|
497
|
+
if (!this.current(run))
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
backoff = INITIAL_BACKOFF_MS;
|
|
501
|
+
const attempt = { stopped: null, emitted: false };
|
|
502
|
+
const startedAt = this.deps.now();
|
|
503
|
+
await runListener({
|
|
504
|
+
streamUrl: minted.streamUrl,
|
|
505
|
+
ticket: minted.ticket,
|
|
506
|
+
leaseMs: minted.leaseMs,
|
|
507
|
+
resumeIds: capResumeIds([...this.seenIds, ...this.digest.heldIds()]),
|
|
508
|
+
}, {
|
|
509
|
+
fetch: this.deps.fetch,
|
|
510
|
+
sleep,
|
|
511
|
+
now: this.deps.now,
|
|
512
|
+
readIdleTimeoutMs: this.deps.readIdleTimeoutMs,
|
|
513
|
+
onAgentCreated: (created) => {
|
|
514
|
+
this.deps.onAgentCreated(created);
|
|
515
|
+
this.rereadInventory(created.plan === null ? null : created.plan.id);
|
|
516
|
+
},
|
|
517
|
+
signal: run.abort.signal,
|
|
518
|
+
write: (output) => {
|
|
519
|
+
if (!this.current(run))
|
|
520
|
+
return;
|
|
521
|
+
if (output.type === "stopped") {
|
|
522
|
+
attempt.stopped = output;
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
attempt.emitted = true;
|
|
526
|
+
if (output.type === "compaction") {
|
|
527
|
+
this.applyControl(output.control, this.compactionKind());
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (output.type === "clear") {
|
|
531
|
+
this.applyControl(output.control, this.clearKind());
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
this.digest.route(output);
|
|
535
|
+
},
|
|
536
|
+
onState: (state) => {
|
|
537
|
+
if (!this.current(run))
|
|
538
|
+
return;
|
|
539
|
+
this.connectionState = state;
|
|
540
|
+
// DX-5088: the heartbeat failure streak is per stream; a drop ends it, so a quick re-open never inherits the old count.
|
|
541
|
+
if (state !== "streaming")
|
|
542
|
+
this.reporter?.resetFailureStreak();
|
|
543
|
+
// Report `streaming` now, not up to a beat later: the session reads healthy as soon as it is.
|
|
544
|
+
if (state === "streaming" && this.deps.auth() !== null)
|
|
545
|
+
this.reporter?.tick();
|
|
546
|
+
},
|
|
547
|
+
onConnectionAttempt: (abort) => {
|
|
548
|
+
run.forceReconnect = abort;
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
if (!this.current(run))
|
|
552
|
+
return;
|
|
553
|
+
const stopped = attempt.stopped;
|
|
554
|
+
// DX-4233: the listener always ends with a stop record; its absence is a bug, answered as `relay_failed` by `start`'s catch.
|
|
555
|
+
if (stopped === null)
|
|
556
|
+
throw new Error("the stream reader ended without a stop record");
|
|
557
|
+
if (stopped.reason === "lease_expired") {
|
|
558
|
+
refusedInRow = 0;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (stopped.reason === "refused") {
|
|
562
|
+
const provedHealthy = attempt.emitted || this.deps.now() - startedAt >= HEALTHY_CONNECTION_MS;
|
|
563
|
+
refusedInRow = provedHealthy ? 1 : refusedInRow + 1;
|
|
564
|
+
if (refusedInRow >= MAX_REFUSED_TICKETS_IN_A_ROW) {
|
|
565
|
+
return this.terminal(run, "refused", `${stopped.detail} (a freshly minted ticket was refused ${refusedInRow} times in a row)`);
|
|
566
|
+
}
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
return this.terminal(run, stopped.reason, stopped.detail);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { checkedTranscriptPath } from "./session-stats.js";
|
|
2
|
+
/**
|
|
3
|
+
* DX-4233 - the body of the `plan_events_wait` tool: the transcript path is checked (a refusal names it), then the relay waits,
|
|
4
|
+
* and a cancelled request (`signal`, the SDK's `extra.signal`) drops the pending wait. A malformed cursor is refused by the relay.
|
|
5
|
+
*/
|
|
6
|
+
export function servePlanEventsWait(relay, args, signal) {
|
|
7
|
+
const transcriptPath = args.transcript_path === undefined ? undefined : checkedTranscriptPath(args.transcript_path, "plan_events_wait transcript_path");
|
|
8
|
+
return relay.wait({ planId: args.plan_id, cursor: args.cursor, timeoutMs: args.timeout_ms, transcriptPath }, signal);
|
|
9
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-5237 (DX-4679 section 6) - the `plugin_publish` flow: build a card-type plugin project with its own `npm run build`, archive its source,
|
|
3
|
+
* take a screenshot, and upload all of it as a revision of one of the team's plugins (created on the first publish).
|
|
4
|
+
*
|
|
5
|
+
* The build, the archive and the upload are `@danxbot/plugin-sdk/publish`, the same code the `danx-plugin publish` CLI runs; this module adds
|
|
6
|
+
* what only an agent's tool has: the dashboard client it already holds (its credential, trace and session headers) as the transport, and the
|
|
7
|
+
* screenshot: the built plugin's own render on the dashboard's `/plugin-preview` page (`plugin-screenshot.ts`, DX-5256). No browser, a dashboard
|
|
8
|
+
* without that page or a failed render publishes WITHOUT a screenshot and says why in the result (`none: <reason>`), the same stated degraded mode as `mini_app_publish`.
|
|
9
|
+
*
|
|
10
|
+
* ORDER, so a failure leaves as little behind as it can: the arguments (a blank note is refused here, before the build), then the build (a failed build uploads nothing), the archive, the
|
|
11
|
+
* screenshot, and only then the upload. The dashboard is the only validator; its refusal comes back verbatim with the step it refused.
|
|
12
|
+
*/
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { archiveSource, assertNote, buildPlugin, uploadRevision, withScreenshot } from "@danxbot/plugin-sdk/publish";
|
|
15
|
+
import { capturePluginRender } from "./plugin-screenshot.js";
|
|
16
|
+
/** The agent's authenticated dashboard client as the publish flow's transport. */
|
|
17
|
+
function clientTransport(client) {
|
|
18
|
+
return { request: ({ method, path: route, query, body }) => client.request({ method, path: route, basePath: "", query, body }) };
|
|
19
|
+
}
|
|
20
|
+
export async function pluginPublish(client, args, deps) {
|
|
21
|
+
assertNote(args.note);
|
|
22
|
+
const projectDir = path.resolve(deps.cwd, args.dir);
|
|
23
|
+
const built = await buildPlugin(projectDir);
|
|
24
|
+
const source = await archiveSource(projectDir);
|
|
25
|
+
const shot = await capturePluginRender(built, deps.baseUrl, { rootAbs: projectDir, cwd: deps.cwd });
|
|
26
|
+
const toUpload = shot.png_base64 === null ? built : withScreenshot(built, Buffer.from(shot.png_base64, "base64"));
|
|
27
|
+
const result = await uploadRevision(clientTransport(client), {
|
|
28
|
+
plugin: args.plugin,
|
|
29
|
+
name: args.name,
|
|
30
|
+
note: args.note,
|
|
31
|
+
built: toUpload,
|
|
32
|
+
source,
|
|
33
|
+
dashboardUrl: deps.baseUrl,
|
|
34
|
+
});
|
|
35
|
+
if (!result.ok)
|
|
36
|
+
return { ok: false, status: result.status, body: result.body, plugin: args.plugin, step: result.step };
|
|
37
|
+
return { ok: true, status: result.status, body: { ...result.body, screenshot: shot.screenshot } };
|
|
38
|
+
}
|