@mirrowel/opencode-openshare 0.2.0 → 0.3.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -26
- package/ci/README.md +36 -9
- package/ci/openshare.js +547 -82
- package/dist/binding-queue.js +2 -2
- package/dist/commands.js +18 -15
- package/dist/config.d.ts +34 -15
- package/dist/config.js +127 -35
- package/dist/contract.d.ts +5 -5
- package/dist/contract.js +5 -5
- package/dist/core.d.ts +13 -1
- package/dist/core.js +491 -157
- package/dist/dialogs.d.ts +9 -10
- package/dist/dialogs.js +25 -28
- package/dist/redact.d.ts +46 -0
- package/dist/redact.js +130 -0
- package/dist/settings.d.ts +11 -8
- package/dist/settings.js +233 -91
- package/dist/share-flow.d.ts +89 -9
- package/dist/share-flow.js +172 -22
- package/dist/tui.js +449 -115
- package/dist/ui-prefs.js +6 -6
- package/dist/v2-server.js +5 -6
- package/openshare.config.json +1 -2
- package/package.json +1 -1
- package/src/banner.txt +1 -1
- package/src/binding-queue.ts +2 -2
- package/src/commands.ts +18 -15
- package/src/config.ts +129 -32
- package/src/contract.ts +5 -5
- package/src/core.ts +468 -158
- package/src/dialogs.tsx +29 -32
- package/src/redact.ts +134 -0
- package/src/settings.ts +225 -83
- package/src/share-flow.ts +180 -23
- package/src/sidebar.tsx +7 -7
- package/src/tui.tsx +22 -24
- package/src/ui-prefs.ts +6 -6
- package/src/v2-server.ts +5 -6
package/src/core.ts
CHANGED
|
@@ -36,18 +36,20 @@
|
|
|
36
36
|
// Session metadata shape (metadata.openshare):
|
|
37
37
|
// - all-mode binding: { id, secret, rev, url }
|
|
38
38
|
// - manual-mode: { id, secret, rev, url, enabled: true, opts? }
|
|
39
|
-
// opts: {
|
|
39
|
+
// opts: { ttlHours?, authLogin?, authPassword?, modelLabel? } optional
|
|
40
40
|
// per-session overrides (capture at activation; modelLabel applies
|
|
41
41
|
// immediately, the rest at create/attach).
|
|
42
42
|
// - The plugin strips this key from everything it syncs — the write
|
|
43
43
|
// secret must never reach viewers.
|
|
44
44
|
|
|
45
45
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
46
|
+
import { homedir } from "node:os"
|
|
46
47
|
import { createHash } from "node:crypto"
|
|
47
|
-
import { join } from "node:path"
|
|
48
|
-
import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken, type ShareConfig, type ShareMode, type ServerEntry } from "./config.js"
|
|
48
|
+
import { isAbsolute, join } from "node:path"
|
|
49
|
+
import { METADATA_KEY, DEFAULTS, configHomePath, configSourceMap, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken, type ShareConfig, type ShareMode, type ServerEntry } from "./config.js"
|
|
49
50
|
import { CONTRACT_VERSION, cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js"
|
|
50
51
|
import { queueBindingWrite } from "./binding-queue.js"
|
|
52
|
+
import { activeRules, redactItem, type RedactionHit } from "./redact.js"
|
|
51
53
|
|
|
52
54
|
// --- minimal structural types (hand-written: the standalone bundle must
|
|
53
55
|
// not depend on the plugin SDK at runtime; shapes verified against
|
|
@@ -86,7 +88,7 @@ export type ShareEvent =
|
|
|
86
88
|
| { type: "session.deleted"; sessionID: string }
|
|
87
89
|
|
|
88
90
|
export type SessionOpts = {
|
|
89
|
-
|
|
91
|
+
ttlHours?: number
|
|
90
92
|
authLogin?: string
|
|
91
93
|
authPassword?: string
|
|
92
94
|
modelLabel?: string
|
|
@@ -121,6 +123,7 @@ type RemoteState = {
|
|
|
121
123
|
authBlocked: boolean
|
|
122
124
|
pending: Array<{ items: unknown[]; status: string; seq?: number; wire?: string }> // chunk-frames awaiting ack (spool; wire = prebuilt frame string)
|
|
123
125
|
frozenRemote?: boolean // server froze the owner mid-session: stop writing
|
|
126
|
+
warnedNoGate?: boolean // B2: one-shot "requested gate missing" warning sent
|
|
124
127
|
spoolAppendFailed?: boolean // last append errored: memory-only frame exists
|
|
125
128
|
spoolSaveTimer: ReturnType<typeof setTimeout> | null // debounced ack-path spool rewrite
|
|
126
129
|
authWaiter?: (() => void) | undefined // frame-auth handshake resolver
|
|
@@ -146,7 +149,12 @@ type SessionState = {
|
|
|
146
149
|
badSignal: boolean
|
|
147
150
|
lastSentStatus: string | null
|
|
148
151
|
closed: boolean
|
|
149
|
-
|
|
152
|
+
/** last create/attach refusal reason surfaced to the TUI (cleared on a
|
|
153
|
+
* successful init-ok); rides the session binding so /share failures can
|
|
154
|
+
* say WHY instead of a generic timeout */
|
|
155
|
+
lastError: string | null
|
|
156
|
+
/** server/token policy clamped our requested TTL: {requested, effective} */
|
|
157
|
+
ttlClamped: { requested: number; effective: number } | null
|
|
150
158
|
binding: Record<string, unknown> | null // mirrors what was written into session metadata (v2 map)
|
|
151
159
|
bindingQueried: boolean
|
|
152
160
|
/** per-session server opt-outs (TUI "stop sharing here" / "leave as-is"):
|
|
@@ -222,9 +230,9 @@ export type Engine = {
|
|
|
222
230
|
config: () => ShareConfig
|
|
223
231
|
}
|
|
224
232
|
|
|
225
|
-
// Stderr policy
|
|
226
|
-
//
|
|
227
|
-
//
|
|
233
|
+
// Stderr policy: in manual mode (TUI present) stderr is for ACTUAL ERRORS
|
|
234
|
+
// only. Informational lines (live:/url file:/mode banner, lifecycle
|
|
235
|
+
// traces) are suppressed because the TUI surfaces the URL itself.
|
|
228
236
|
// All-mode (CI/server-only) keeps its stderr contract (announce lines,
|
|
229
237
|
// silenceable via announceStderr). Error-class lines always reach stderr.
|
|
230
238
|
let stderrInfoAllowed = true
|
|
@@ -279,6 +287,22 @@ function redact<T extends { metadata?: Record<string, unknown> }>(info: T): T {
|
|
|
279
287
|
return { ...info, metadata }
|
|
280
288
|
}
|
|
281
289
|
|
|
290
|
+
/** Wave 3 data-inventory split: machine-local facts leave the PUBLIC
|
|
291
|
+
* session item. Viewers recover them from the envelope's private block
|
|
292
|
+
* (gated+authenticated shares only); anonymous shares never store them. */
|
|
293
|
+
function stripLocalFacts<T>(info: T): T {
|
|
294
|
+
if (!info || typeof info !== "object") return info
|
|
295
|
+
const out = { ...(info as Record<string, unknown>) }
|
|
296
|
+
let changed = false
|
|
297
|
+
for (const k of ["directory", "worktree"]) {
|
|
298
|
+
if (out[k] !== undefined) {
|
|
299
|
+
delete out[k]
|
|
300
|
+
changed = true
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return changed ? (out as T) : info
|
|
304
|
+
}
|
|
305
|
+
|
|
282
306
|
function shareFlag(info: SessionInfo | undefined): { enabled: boolean; opts: SessionOpts | undefined } {
|
|
283
307
|
const raw = info?.metadata?.[METADATA_KEY]
|
|
284
308
|
if (!raw || typeof raw !== "object") return { enabled: false, opts: undefined }
|
|
@@ -288,7 +312,9 @@ function shareFlag(info: SessionInfo | undefined): { enabled: boolean; opts: Ses
|
|
|
288
312
|
const opts: SessionOpts = {}
|
|
289
313
|
if (optsRaw && typeof optsRaw === "object") {
|
|
290
314
|
const o = optsRaw as Record<string, unknown>
|
|
291
|
-
|
|
315
|
+
// legacy days-shaped opts migrate x24 (old bindings)
|
|
316
|
+
if (typeof o.ttlHours === "number" && o.ttlHours > 0) opts.ttlHours = Math.floor(o.ttlHours)
|
|
317
|
+
else if (typeof o.ttlDays === "number" && o.ttlDays > 0) opts.ttlHours = Math.floor(o.ttlDays * 24)
|
|
292
318
|
if (typeof o.authLogin === "string") opts.authLogin = o.authLogin
|
|
293
319
|
if (typeof o.authPassword === "string") opts.authPassword = o.authPassword
|
|
294
320
|
if (typeof o.modelLabel === "string") opts.modelLabel = o.modelLabel
|
|
@@ -296,6 +322,32 @@ function shareFlag(info: SessionInfo | undefined): { enabled: boolean; opts: Ses
|
|
|
296
322
|
return { enabled: true, opts: Object.keys(opts).length ? opts : undefined }
|
|
297
323
|
}
|
|
298
324
|
|
|
325
|
+
/** The LIVE engine's resolved target urls (shared module state: the TUI
|
|
326
|
+
* entry reads this so surfaces that gate on "is anything configured" agree
|
|
327
|
+
* with what the engine actually targets - file config alone can miss
|
|
328
|
+
* plugin-options-layer config the TUI cannot see). */
|
|
329
|
+
let liveTargets: string[] = []
|
|
330
|
+
// the live engine registers its counter here (the TUI imports the
|
|
331
|
+
// accessor without holding the engine instance)
|
|
332
|
+
let statsProvider: ((sid: string) => { messages: number; toolCalls: number }) | null = null
|
|
333
|
+
|
|
334
|
+
// Wave 3 preflight ledger: per-session redaction hits (count + first
|
|
335
|
+
// samples, verbatim, so false positives are visible in the consent
|
|
336
|
+
// preview before anything is published)
|
|
337
|
+
const redactionLedger = new Map<string, { count: number; samples: RedactionHit[] }>()
|
|
338
|
+
|
|
339
|
+
/** Preflight stats for the consent dialog: message/tool counts plus the
|
|
340
|
+
* redaction ledger (what would be stripped before publishing). */
|
|
341
|
+
export function sessionStatsFor(sid: string): { messages: number; toolCalls: number; redaction: { count: number; samples: RedactionHit[] } } {
|
|
342
|
+
const led = redactionLedger.get(sid) ?? { count: 0, samples: [] as RedactionHit[] }
|
|
343
|
+
const counts = statsProvider ? statsProvider(sid) : { messages: 0, toolCalls: 0 }
|
|
344
|
+
return { ...counts, redaction: led }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function engineTargetUrls(): string[] {
|
|
348
|
+
return liveTargets
|
|
349
|
+
}
|
|
350
|
+
|
|
299
351
|
export function createEngine(deps: EngineDeps): Engine {
|
|
300
352
|
let cfg = loadConfig(deps.pluginDir, deps.options)
|
|
301
353
|
const mode: ShareMode = resolveShareMode(cfg, deps)
|
|
@@ -342,7 +394,15 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
342
394
|
type ServerTarget = { url: string; entry: ServerEntry }
|
|
343
395
|
const resolveTargets = (): ServerTarget[] => {
|
|
344
396
|
const merged: Record<string, ServerEntry> = {}
|
|
345
|
-
|
|
397
|
+
const hasServers = !!cfg.servers && Object.keys(cfg.servers).length > 0
|
|
398
|
+
// layer-aware legacy rule: a FILE workerUrl beside a FILE servers group
|
|
399
|
+
// would silently dual-home the session; an ENV workerUrl is the
|
|
400
|
+
// highest-priority layer and always joins.
|
|
401
|
+
const envWorker = !!process.env.OPENSHARE_WORKER_URL
|
|
402
|
+
if (cfg.workerUrl && (!hasServers || envWorker)) merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true }
|
|
403
|
+
if (cfg.workerUrl && hasServers && !envWorker && !cfg.servers![cfg.workerUrl]) {
|
|
404
|
+
log(`legacy workerUrl ${cfg.workerUrl} ignored: a servers group is configured - remove one of the two`)
|
|
405
|
+
}
|
|
346
406
|
if (cfg.servers) for (const [u, e] of Object.entries(cfg.servers)) merged[u] = { ...merged[u], ...e }
|
|
347
407
|
const urls = Object.keys(merged)
|
|
348
408
|
const actives = urls.filter((u) => merged[u].active)
|
|
@@ -350,6 +410,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
350
410
|
return chosen.map((url) => ({ url, entry: merged[url] }))
|
|
351
411
|
}
|
|
352
412
|
let targets = resolveTargets()
|
|
413
|
+
liveTargets = targets.map((t) => t.url)
|
|
353
414
|
|
|
354
415
|
// ---- per-server credentials (identity) ----------------------------------
|
|
355
416
|
// Each server enrolls INDEPENDENTLY: a legacy root CREATE_TOKEN works
|
|
@@ -381,39 +442,99 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
381
442
|
}
|
|
382
443
|
seedCredentials()
|
|
383
444
|
const ENROLL_RETRY_MS = 60000
|
|
445
|
+
// B4 Class B (server-SUGGESTED modelLabel/shareMode): fetch once per
|
|
446
|
+
// server per boot, adopt ONLY keys with no explicit local source, and
|
|
447
|
+
// persist the adoption to the settings home (owner: no repeated CF
|
|
448
|
+
// fetches). One flag per URL for the whole process lifetime.
|
|
449
|
+
const setsFetched = new Set<string>()
|
|
450
|
+
const adoptServerSuggestions = async (url: string, token: string) => {
|
|
451
|
+
if (setsFetched.has(url)) return
|
|
452
|
+
setsFetched.add(url)
|
|
453
|
+
try {
|
|
454
|
+
const r = await fetch(`${url}/api/token/sets`, {
|
|
455
|
+
headers: { authorization: `Bearer ${token}` },
|
|
456
|
+
signal: AbortSignal.timeout(8000),
|
|
457
|
+
})
|
|
458
|
+
const body = r.ok ? ((await r.json().catch(() => null)) as { sets?: Record<string, { modelLabel?: string; shareMode?: string }> } | null) : null
|
|
459
|
+
const sets = body?.sets ?? {}
|
|
460
|
+
const pick = sets[cfg.configSet ?? ""] ?? Object.values(sets)[0]
|
|
461
|
+
if (!pick) return
|
|
462
|
+
const wantModel = typeof pick.modelLabel === "string" && !pick.modelLabel ? undefined : pick.modelLabel
|
|
463
|
+
const wantMode = pick.shareMode === "all" || pick.shareMode === "manual" ? pick.shareMode : undefined
|
|
464
|
+
// adopt only what is UNSPECIFIED locally: modelLabel sits at the
|
|
465
|
+
// default template, shareMode is unset and env carry no override
|
|
466
|
+
const src = configSourceMap(deps.pluginDir ?? "")
|
|
467
|
+
const modelUnset = cfg.modelLabel === DEFAULTS.modelLabel && !src.modelLabel
|
|
468
|
+
const modeUnset = cfg.shareMode === undefined && !src.shareMode
|
|
469
|
+
const adoptModel = wantModel && modelUnset
|
|
470
|
+
const adoptMode = wantMode && modeUnset
|
|
471
|
+
if (!adoptModel && !adoptMode) return
|
|
472
|
+
// surgical write into the settings home: read, patch just these
|
|
473
|
+
// keys when they are absent, write back (never clobber explicit
|
|
474
|
+
// values that appeared since load)
|
|
475
|
+
const home = configHomePath()
|
|
476
|
+
if (!home) return
|
|
477
|
+
let doc: Record<string, unknown> = {}
|
|
478
|
+
try {
|
|
479
|
+
doc = JSON.parse(readFileSync(home, "utf8"))
|
|
480
|
+
if (!doc || typeof doc !== "object") doc = {}
|
|
481
|
+
} catch {}
|
|
482
|
+
let dirty = false
|
|
483
|
+
if (adoptModel && doc.modelLabel === undefined) { doc.modelLabel = wantModel; dirty = true; log(`${url}: adopted modelLabel from the server config set`) }
|
|
484
|
+
if (adoptMode && doc.shareMode === undefined) { doc.shareMode = wantMode; dirty = true; log(`${url}: adopted shareMode from the server config set`) }
|
|
485
|
+
if (dirty) writeFileSync(home, JSON.stringify(doc, null, 2) + "\n")
|
|
486
|
+
} catch {
|
|
487
|
+
// suggestions are best-effort; a failed fetch never blocks sharing
|
|
488
|
+
}
|
|
489
|
+
}
|
|
384
490
|
|
|
385
491
|
const ensureCredential = async (url: string): Promise<string> => {
|
|
386
492
|
const c = credOf(url)
|
|
387
493
|
const target = targets.find((t) => t.url === url)
|
|
388
|
-
if (c.credential)
|
|
494
|
+
if (c.credential) {
|
|
495
|
+
void adoptServerSuggestions(url, c.credential)
|
|
496
|
+
return c.credential
|
|
497
|
+
}
|
|
389
498
|
if (Date.now() - c.lastEnrollAt < ENROLL_RETRY_MS) return ""
|
|
390
499
|
c.lastEnrollAt = Date.now()
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
if (b?.ok && typeof b.token === "string") {
|
|
405
|
-
c.credential = b.token
|
|
406
|
-
c.error = ""
|
|
407
|
-
const savedTo = saveServerToken(url, c.credential)
|
|
408
|
-
log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`)
|
|
409
|
-
return c.credential
|
|
500
|
+
// A configured code IS expressed onboarding intent (headless-safe: CI
|
|
501
|
+
// carries it precisely to enroll). Without one, never auto-enroll:
|
|
502
|
+
// public servers share anonymously (the ask-once TUI flow offers an
|
|
503
|
+
// account), private ones refuse until the user connects via
|
|
504
|
+
// /openshare.
|
|
505
|
+
const code = target?.entry.code ?? ""
|
|
506
|
+
if (!code) {
|
|
507
|
+
try {
|
|
508
|
+
const h = await fetch(`${url}/health`, { signal: AbortSignal.timeout(8000) })
|
|
509
|
+
const hb = h.ok ? ((await h.json().catch(() => null)) as { auth?: { mode?: string } } | null) : null
|
|
510
|
+
if (hb?.auth?.mode === "public") {
|
|
511
|
+
c.error = "" // anonymous standing - tokenless create works
|
|
512
|
+
return ""
|
|
410
513
|
}
|
|
411
|
-
c.error =
|
|
412
|
-
}
|
|
413
|
-
c.error =
|
|
414
|
-
}
|
|
415
|
-
|
|
514
|
+
c.error = "private server - onboard via /openshare (needs an access code)"
|
|
515
|
+
} catch (e) {
|
|
516
|
+
c.error = `server unreachable: ${(e as Error)?.message || e}`
|
|
517
|
+
}
|
|
518
|
+
logError(`no credential for ${url}: ${c.error}`)
|
|
519
|
+
return ""
|
|
520
|
+
}
|
|
521
|
+
try {
|
|
522
|
+
const r = await fetch(`${url}/api/enroll`, {
|
|
523
|
+
method: "POST",
|
|
524
|
+
headers: { "content-type": "application/json" },
|
|
525
|
+
body: JSON.stringify({ code }),
|
|
526
|
+
signal: AbortSignal.timeout(10000),
|
|
527
|
+
})
|
|
528
|
+
const b = r.ok ? ((await r.json().catch(() => null)) as { ok?: boolean; token?: string; userId?: string; label?: string; error?: string } | null) : null
|
|
529
|
+
if (b?.ok && typeof b.token === "string") {
|
|
530
|
+
c.credential = b.token
|
|
531
|
+
c.error = ""
|
|
532
|
+
const savedTo = saveServerToken(url, c.credential)
|
|
533
|
+
log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`)
|
|
534
|
+
void adoptServerSuggestions(url, c.credential)
|
|
535
|
+
return c.credential
|
|
416
536
|
}
|
|
537
|
+
c.error = `enroll refused: ${b?.error ?? `HTTP ${r.status}`}`
|
|
417
538
|
} catch (e) {
|
|
418
539
|
c.error = `enrollment failed: ${(e as Error)?.message || e}`
|
|
419
540
|
}
|
|
@@ -550,7 +671,8 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
550
671
|
badSignal: false,
|
|
551
672
|
lastSentStatus: null,
|
|
552
673
|
closed: false,
|
|
553
|
-
|
|
674
|
+
lastError: null,
|
|
675
|
+
ttlClamped: null,
|
|
554
676
|
binding: null,
|
|
555
677
|
bindingQueried: false,
|
|
556
678
|
optOuts: new Set(),
|
|
@@ -572,6 +694,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
572
694
|
spoolRoot = spoolDirFor(cfg)
|
|
573
695
|
const before = new Set(targets.map((t) => t.url))
|
|
574
696
|
targets = resolveTargets()
|
|
697
|
+
liveTargets = targets.map((t) => t.url)
|
|
575
698
|
seedCredentials()
|
|
576
699
|
clampToPolicy()
|
|
577
700
|
if (debugEvents) {
|
|
@@ -775,12 +898,14 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
775
898
|
// is absent from `servers`, and dropping the marker would let the next
|
|
776
899
|
// refreshOptOuts resurrect (and re-create) the opted-out server
|
|
777
900
|
for (const url of st.optOuts) if (!servers[url]) servers[url] = { optedOut: true }
|
|
778
|
-
// A binding in session metadata means opted-in
|
|
779
|
-
// modes
|
|
780
|
-
// gate fall through to the consent dialog on an already-shared
|
|
781
|
-
// live is
|
|
901
|
+
// A binding in session metadata means opted-in, so enabled: true is
|
|
902
|
+
// written in BOTH modes. All-mode bindings without `enabled` made the
|
|
903
|
+
// TUI gate fall through to the consent dialog on an already-shared
|
|
904
|
+
// session; live is uniformly "url && enabled !== false".
|
|
782
905
|
const value: Record<string, unknown> = { v: 2, servers, enabled: true }
|
|
783
906
|
if (mode === "manual" && st.opts) value.opts = st.opts
|
|
907
|
+
if (st.lastError) value.lastError = st.lastError
|
|
908
|
+
if (st.ttlClamped) value.ttlClamped = st.ttlClamped
|
|
784
909
|
st.binding = value
|
|
785
910
|
st.wroteBinding = true
|
|
786
911
|
await writeMetadata(st, value)
|
|
@@ -829,9 +954,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
829
954
|
try {
|
|
830
955
|
appendFileSync(queuePath(st, r.url), (f.wire ?? JSON.stringify(f)) + "\n")
|
|
831
956
|
} catch (e) {
|
|
832
|
-
// the frame lives ONLY in memory now
|
|
833
|
-
// spoolLoad
|
|
834
|
-
//
|
|
957
|
+
// the frame lives ONLY in memory now, so flag the remote: the next
|
|
958
|
+
// spoolLoad must NOT replace in-memory pending with the shorter disk
|
|
959
|
+
// queue (silent loss otherwise).
|
|
835
960
|
r.spoolAppendFailed = true
|
|
836
961
|
logError("spool append failed", (e as Error)?.message || e)
|
|
837
962
|
}
|
|
@@ -852,10 +977,10 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
852
977
|
}
|
|
853
978
|
const spoolLoad = (st: SessionState, r: RemoteState) => {
|
|
854
979
|
// meta first (share binding recovery), in its OWN try: a missing meta
|
|
855
|
-
// file is normal (
|
|
856
|
-
//
|
|
857
|
-
//
|
|
858
|
-
//
|
|
980
|
+
// file is normal (written only after the first successful handshake)
|
|
981
|
+
// and must NOT touch pending. A blanket catch here wipes in-memory
|
|
982
|
+
// pending AND skips the queue read, so everything queued before the
|
|
983
|
+
// first successful connect is lost.
|
|
859
984
|
try {
|
|
860
985
|
const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"))
|
|
861
986
|
// metadata binding (db) is authoritative when both exist and disagree
|
|
@@ -1025,28 +1150,96 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1025
1150
|
// Append: CI reads the file after each run; keeping every share
|
|
1026
1151
|
// URL lets one file serve a whole workflow (the legacy original
|
|
1027
1152
|
// overwrote with the latest only).
|
|
1028
|
-
const file = cfg.urlFile
|
|
1153
|
+
const file = cfg.urlFile
|
|
1154
|
+
? isAbsolute(cfg.urlFile)
|
|
1155
|
+
? cfg.urlFile
|
|
1156
|
+
: join(deps.directory ?? process.cwd(), cfg.urlFile)
|
|
1157
|
+
: join(deps.directory ?? process.cwd(), "openshare.urls.txt")
|
|
1029
1158
|
try {
|
|
1030
|
-
|
|
1159
|
+
// "json" format: one machine-readable object per line (CI parses
|
|
1160
|
+
// fields instead of URLs). Status is the engine's current signal
|
|
1161
|
+
// at announce time; the completed flip updates the SHARE, not
|
|
1162
|
+
// this file line.
|
|
1163
|
+
const line =
|
|
1164
|
+
cfg.urlFileFormat === "json"
|
|
1165
|
+
? JSON.stringify({
|
|
1166
|
+
id: r.shareId,
|
|
1167
|
+
url,
|
|
1168
|
+
status: st.lastSentStatus ?? "waiting",
|
|
1169
|
+
gated: (st.opts?.authLogin ?? cfg.authLogin ?? "") !== "",
|
|
1170
|
+
}) + "\n"
|
|
1171
|
+
: url + "\n"
|
|
1172
|
+
appendFileSync(file, line)
|
|
1031
1173
|
if (cfg.announceStderr && (file !== lastUrlFile || !urlFileAnnounced)) {
|
|
1032
1174
|
urlFileAnnounced = true
|
|
1033
1175
|
lastUrlFile = file
|
|
1034
1176
|
log(`url file: ${file}`)
|
|
1035
1177
|
}
|
|
1036
|
-
} catch {
|
|
1178
|
+
} catch (e) {
|
|
1179
|
+
// a CI pipeline reads this file after the process exits; a silent
|
|
1180
|
+
// failure means the run's share URL is simply gone
|
|
1181
|
+
logError(`url file write failed (${file}):`, (e as Error)?.message || e)
|
|
1182
|
+
}
|
|
1037
1183
|
}
|
|
1038
1184
|
}
|
|
1039
1185
|
|
|
1186
|
+
let halfAuthWarned = false
|
|
1040
1187
|
const authFrame = (st: SessionState) => {
|
|
1041
1188
|
// always send both (empty strings included): attach with empty values
|
|
1042
|
-
// REMOVES protection
|
|
1189
|
+
// REMOVES protection - an intentional owner action. Session opts
|
|
1043
1190
|
// override the global config when set.
|
|
1044
1191
|
const login = st.opts?.authLogin ?? cfg.authLogin ?? ""
|
|
1045
1192
|
const password = st.opts?.authPassword ?? cfg.authPassword ?? ""
|
|
1193
|
+
// both-or-none: the server builds a gate only from a full pair, so a
|
|
1194
|
+
// half pair silently means "no gate" - refuse to send one and say so
|
|
1195
|
+
if ((login === "") !== (password === "")) {
|
|
1196
|
+
if (!halfAuthWarned) {
|
|
1197
|
+
halfAuthWarned = true
|
|
1198
|
+
logError("read auth needs BOTH a login and a password - gate disabled until fixed")
|
|
1199
|
+
}
|
|
1200
|
+
return { authLogin: "", authPassword: "" }
|
|
1201
|
+
}
|
|
1046
1202
|
return { authLogin: login, authPassword: password }
|
|
1047
1203
|
}
|
|
1048
1204
|
|
|
1049
|
-
const ttlFor = (st: SessionState) => st.opts?.
|
|
1205
|
+
const ttlFor = (st: SessionState) => st.opts?.ttlHours ?? cfg.ttlHours
|
|
1206
|
+
|
|
1207
|
+
// free-form labels for team directories/admin listings: ${NAME} refs
|
|
1208
|
+
// resolve from the process env at send time; unresolved refs stay
|
|
1209
|
+
// literal (visible beats silently-empty). Cached env lookup, max 20
|
|
1210
|
+
// keys/500 chars per value (config loader caps the same).
|
|
1211
|
+
const metaFor = (): Record<string, string> | undefined => {
|
|
1212
|
+
if (!cfg.meta || typeof cfg.meta !== "object") return undefined
|
|
1213
|
+
const out: Record<string, string> = {}
|
|
1214
|
+
let n = 0
|
|
1215
|
+
for (const [k, v] of Object.entries(cfg.meta)) {
|
|
1216
|
+
if (n >= 20) break
|
|
1217
|
+
out[k] = v.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, name) => process.env[name] ?? m)
|
|
1218
|
+
n++
|
|
1219
|
+
}
|
|
1220
|
+
return Object.keys(out).length ? out : undefined
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/** Apply a metadata opts update. On a CHANGED opts set for a session with
|
|
1224
|
+
* live sockets, re-send the attach frame over each open socket: the
|
|
1225
|
+
* worker's attach path applies read-auth retroactively (rotating viewer
|
|
1226
|
+
* tokens on change) and now also honors an explicit ttlHours override -
|
|
1227
|
+
* so settings heal immediately instead of waiting for a reconnect.
|
|
1228
|
+
* Re-attach is idempotent (no once-guard on init; earlySyncs empty at
|
|
1229
|
+
* steady state) and revalidates the token. */
|
|
1230
|
+
const applyOpts = (st: SessionState, opts: SessionOpts | undefined) => {
|
|
1231
|
+
if (!opts) return
|
|
1232
|
+
const prev = st.opts ? JSON.stringify(st.opts) : ""
|
|
1233
|
+
st.opts = opts
|
|
1234
|
+
if (prev === JSON.stringify(opts)) return
|
|
1235
|
+
for (const [url, r] of st.remotes) {
|
|
1236
|
+
if (!r.shareId || !r.secret || !r.wsReady) continue
|
|
1237
|
+
const attach: Record<string, unknown> = { id: r.shareId, secret: r.secret, ...authFrame(st) }
|
|
1238
|
+
if (st.opts?.ttlHours) attach.ttlHours = st.opts.ttlHours
|
|
1239
|
+
const sent = wsSend(r, { op: "init", attach })
|
|
1240
|
+
if (sent) log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlHours ? ` (ttl ${st.opts.ttlHours}h)` : ""}`)
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1050
1243
|
|
|
1051
1244
|
/** Best-effort cleanup of a share we are about to abandon (refused
|
|
1052
1245
|
* attach, mismatch recreate). Prevents husk litter; failures are fine —
|
|
@@ -1065,9 +1258,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1065
1258
|
|
|
1066
1259
|
/** Single-flight connect queuing: multiple paths (activation, event
|
|
1067
1260
|
* reconnect, backfill chain) may want a socket before any connect has
|
|
1068
|
-
* STARTED
|
|
1069
|
-
* and double connects
|
|
1070
|
-
*
|
|
1261
|
+
* STARTED. The connecting flag alone guards execution, not the queue,
|
|
1262
|
+
* and double connects create duplicate shares and init storms (one
|
|
1263
|
+
* create per socket). */
|
|
1071
1264
|
const queueConnect = (st: SessionState, r: RemoteState) => {
|
|
1072
1265
|
if (st.closed || r.connectQueued || r.connecting || r.authBlocked || r.collided) return
|
|
1073
1266
|
r.connectQueued = true
|
|
@@ -1130,7 +1323,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1130
1323
|
st.remotes.delete(url)
|
|
1131
1324
|
}
|
|
1132
1325
|
// collisions resolved by the TUI: the binding entry for the server
|
|
1133
|
-
// was removed
|
|
1326
|
+
// was removed - re-arm unless the same patch also opted out
|
|
1134
1327
|
for (const r of [...st.remotes.values()]) {
|
|
1135
1328
|
if (!r.collided || st.optOuts.has(r.url)) continue
|
|
1136
1329
|
if (entryUrls.has(r.url)) continue
|
|
@@ -1143,17 +1336,38 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1143
1336
|
r.announced = false
|
|
1144
1337
|
queueConnect(st, r)
|
|
1145
1338
|
}
|
|
1339
|
+
// re-share after "leave as-is": the entry kept its id/secret and the
|
|
1340
|
+
// opt-out marker is gone - re-arm a remote that ATTACHES the same
|
|
1341
|
+
// page (same URL), then backfill so the frozen gap catches up
|
|
1342
|
+
// (dedupe hashes describe the OLD remote set, not this revival)
|
|
1343
|
+
let rearmed = false
|
|
1344
|
+
for (const [url, e] of Object.entries(servers as Record<string, unknown>)) {
|
|
1345
|
+
if (st.optOuts.has(url) || st.remotes.has(url)) continue
|
|
1346
|
+
const ee = e as Record<string, unknown>
|
|
1347
|
+
if (typeof ee.id !== "string" || typeof ee.secret !== "string" || !ee.id) continue
|
|
1348
|
+
const r = remoteFor(st, url)
|
|
1349
|
+
r.shareId = ee.id
|
|
1350
|
+
r.secret = ee.secret
|
|
1351
|
+
r.ackedRev = typeof ee.rev === "number" ? ee.rev : 0
|
|
1352
|
+
log(`re-share: re-arming ${url.slice(0, 24)} on share ${ee.id}`)
|
|
1353
|
+
queueConnect(st, r)
|
|
1354
|
+
rearmed = true
|
|
1355
|
+
}
|
|
1356
|
+
if (rearmed) {
|
|
1357
|
+
st.sentHash.clear()
|
|
1358
|
+
chain = chain.then(() => backfill(st)).catch((e) => logError("re-share backfill error", (e as Error)?.message || e))
|
|
1359
|
+
}
|
|
1146
1360
|
} catch {}
|
|
1147
1361
|
}
|
|
1148
1362
|
|
|
1149
1363
|
const connect = async (st: SessionState, r: RemoteState) => {
|
|
1150
1364
|
if (st.closed || r.connecting || r.authBlocked || r.collided) return
|
|
1151
|
-
// "connecting" spans the WHOLE handshake: socket open
|
|
1152
|
-
// is only
|
|
1153
|
-
// event arriving mid-handshake must NEVER spawn a second socket
|
|
1154
|
-
// old connect() returned after merely SENDING init
|
|
1365
|
+
// "connecting" spans the WHOLE handshake: socket open plus init ack.
|
|
1366
|
+
// It is cleared only on init-ok, socket death or the init timeout. An
|
|
1367
|
+
// event arriving mid-handshake must NEVER spawn a second socket: the
|
|
1368
|
+
// old connect() returned after merely SENDING init, a second socket
|
|
1155
1369
|
// raced it, and the loser's late init-ok poisoned the winner's state
|
|
1156
|
-
//
|
|
1370
|
+
// (duplicate shares, mismatch storms, empty pages).
|
|
1157
1371
|
r.connecting = true
|
|
1158
1372
|
r.socketSeq += 1
|
|
1159
1373
|
const seq = r.socketSeq
|
|
@@ -1169,9 +1383,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1169
1383
|
spoolLoad(st, r)
|
|
1170
1384
|
askPolicy(r.url)
|
|
1171
1385
|
// was this socket's init an ATTACH (resume) or a CREATE? A create
|
|
1172
|
-
// ack legitimately starts at rev 0
|
|
1173
|
-
// attaches only
|
|
1174
|
-
//
|
|
1386
|
+
// ack legitimately starts at rev 0, so the mismatch guard is for
|
|
1387
|
+
// attaches only. Applying it to a create spawns a fresh husk share
|
|
1388
|
+
// with the content dropped.
|
|
1175
1389
|
r.initWasAttach = !!(r.shareId && r.secret)
|
|
1176
1390
|
if (debugEvents) log(`ws ${r.url.slice(0, 24)} #${seq} opening (${r.initWasAttach ? `attach ${r.shareId}` : "create"})`)
|
|
1177
1391
|
// creates need a credential (enrolls on demand — first contact with
|
|
@@ -1259,7 +1473,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1259
1473
|
if (r.initWasAttach) {
|
|
1260
1474
|
wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } })
|
|
1261
1475
|
} else {
|
|
1262
|
-
wsSend(r, { op: "init", create: { sessionID: st.sid,
|
|
1476
|
+
wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...(cfg.configSet ? { set: cfg.configSet } : {}), ...(metaFor() ? { meta: metaFor() } : {}) } })
|
|
1263
1477
|
}
|
|
1264
1478
|
|
|
1265
1479
|
// handshake deadline: init sent but no ack (silent worker death)
|
|
@@ -1387,8 +1601,10 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1387
1601
|
const err = String(frame.error ?? "")
|
|
1388
1602
|
if (/frozen/i.test(err)) {
|
|
1389
1603
|
// owner frozen: every control path is severed server-side.
|
|
1390
|
-
// Re-enrolling would orphan the shares
|
|
1604
|
+
// Re-enrolling would orphan the shares - surface and stop.
|
|
1391
1605
|
r.authBlocked = true
|
|
1606
|
+
st.lastError = `share frozen by the hoster (owner frozen) - contact the server admin`
|
|
1607
|
+
scheduleBindingWrite(st)
|
|
1392
1608
|
logError(`${r.url}: share frozen by the hoster (owner frozen) - edits refused; contact the server admin`)
|
|
1393
1609
|
try {
|
|
1394
1610
|
r.ws?.close(4000, "frozen")
|
|
@@ -1432,14 +1648,16 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1432
1648
|
return
|
|
1433
1649
|
}
|
|
1434
1650
|
// share swept / removed / revoked: start fresh, never merge into a
|
|
1435
|
-
// bad base (mismatch protocol). Content REPLAYS to the new share
|
|
1436
|
-
// pending is never dropped on a reset
|
|
1437
|
-
// husk
|
|
1651
|
+
// bad base (mismatch protocol). Content REPLAYS to the new share,
|
|
1652
|
+
// and pending is never dropped on a reset: dropping it leaves the
|
|
1653
|
+
// recreate as a husk with the title and zero messages.
|
|
1438
1654
|
if (/limit|too many|rate/i.test(err)) {
|
|
1439
1655
|
// quota/429 ("max shares reached", "daily create limit", the
|
|
1440
1656
|
// anonymous caps): an immediate re-init on the same socket is a
|
|
1441
|
-
// hot loop against the server
|
|
1657
|
+
// hot loop against the server - back off through the normal
|
|
1442
1658
|
// reconnect cadence instead
|
|
1659
|
+
st.lastError = `create refused: ${frame.error ?? "rate limited"}`
|
|
1660
|
+
scheduleBindingWrite(st)
|
|
1443
1661
|
logError(`${r.url}: create refused:`, frame.error, "- retrying with backoff")
|
|
1444
1662
|
r.connecting = false
|
|
1445
1663
|
try {
|
|
@@ -1449,23 +1667,25 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1449
1667
|
return
|
|
1450
1668
|
}
|
|
1451
1669
|
logError(`${r.url}: init refused:`, frame.error, "- creating fresh share")
|
|
1670
|
+
st.lastError = `init refused: ${frame.error ?? "unknown"}`
|
|
1671
|
+
scheduleBindingWrite(st)
|
|
1452
1672
|
deleteAbandoned(r)
|
|
1453
1673
|
r.shareId = null
|
|
1454
1674
|
r.secret = null
|
|
1455
1675
|
r.ackedRev = 0
|
|
1456
|
-
wsSend(r, { op: "init", create: { sessionID: st.sid,
|
|
1676
|
+
wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...(cfg.configSet ? { set: cfg.configSet } : {}), ...(metaFor() ? { meta: metaFor() } : {}) } })
|
|
1457
1677
|
return
|
|
1458
1678
|
}
|
|
1459
1679
|
if (r.initWasAttach && typeof frame.rev === "number" && frame.rev < r.ackedRev) {
|
|
1460
|
-
// worker lost data behind our back (ATTACH only
|
|
1461
|
-
// legitimately starts at rev 0
|
|
1462
|
-
//
|
|
1680
|
+
// worker lost data behind our back (ATTACH only: a create ack
|
|
1681
|
+
// legitimately starts at rev 0, and misreading that as data loss
|
|
1682
|
+
// spawns a fresh husk)
|
|
1463
1683
|
logError(`${r.url}: mismatch: worker rev ${frame.rev} < acked ${r.ackedRev} - recreating`)
|
|
1464
1684
|
deleteAbandoned(r)
|
|
1465
1685
|
r.shareId = null
|
|
1466
1686
|
r.secret = null
|
|
1467
1687
|
r.ackedRev = 0
|
|
1468
|
-
wsSend(r, { op: "init", create: { sessionID: st.sid,
|
|
1688
|
+
wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...(cfg.configSet ? { set: cfg.configSet } : {}), ...(metaFor() ? { meta: metaFor() } : {}) } })
|
|
1469
1689
|
return
|
|
1470
1690
|
}
|
|
1471
1691
|
const wasFanoutCreate = !r.initWasAttach && st.everConnected
|
|
@@ -1485,11 +1705,10 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1485
1705
|
clearTimeout(r.initTimer)
|
|
1486
1706
|
r.initTimer = null
|
|
1487
1707
|
}
|
|
1488
|
-
// backoff reset is DEFERRED until the socket proves stable: a
|
|
1489
|
-
//
|
|
1490
|
-
//
|
|
1491
|
-
//
|
|
1492
|
-
// still ready, no reconnect pending) or a second acked sync.
|
|
1708
|
+
// backoff reset is DEFERRED until the socket proves stable: a server
|
|
1709
|
+
// that accepts init then immediately drops would otherwise reconnect
|
|
1710
|
+
// once per second per session-server pair. Stability means 30s alive
|
|
1711
|
+
// (socket still ready, no reconnect pending) or a second acked sync.
|
|
1493
1712
|
if (r.backoffResetTimer) clearTimeout(r.backoffResetTimer)
|
|
1494
1713
|
r.backoffResetTimer = setTimeout(() => {
|
|
1495
1714
|
r.backoffResetTimer = null
|
|
@@ -1499,6 +1718,52 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1499
1718
|
if (frame.policy) applyPolicy(frame.policy, r.url)
|
|
1500
1719
|
r.intentionalClose = false
|
|
1501
1720
|
touch(st)
|
|
1721
|
+
// the ack carries the RESOLVED ttl: surface a server/token clamp to
|
|
1722
|
+
// the binding once, and a successful handshake clears any stale
|
|
1723
|
+
// refusal reason the TUI may be showing
|
|
1724
|
+
const ackTtl = typeof frame.ttlHours === "number" ? frame.ttlHours : (typeof frame.ttlDays === "number" ? frame.ttlDays * 24 : undefined)
|
|
1725
|
+
if (typeof ackTtl === "number") {
|
|
1726
|
+
const wanted = ttlFor(st)
|
|
1727
|
+
const clamp = wanted != null && ackTtl !== wanted ? { requested: wanted, effective: ackTtl } : null
|
|
1728
|
+
if (JSON.stringify(clamp) !== JSON.stringify(st.ttlClamped)) {
|
|
1729
|
+
st.ttlClamped = clamp
|
|
1730
|
+
scheduleBindingWrite(st)
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
if (st.lastError) {
|
|
1734
|
+
st.lastError = null
|
|
1735
|
+
scheduleBindingWrite(st)
|
|
1736
|
+
}
|
|
1737
|
+
// ack-level gate assurance (B2): we asked for a gate (both fields
|
|
1738
|
+
// set) but the server reports none - the page is actually public.
|
|
1739
|
+
// Once per remote; the next init-ok re-checks after a fix.
|
|
1740
|
+
const wantedGate = (st.opts?.authLogin ?? cfg.authLogin ?? "") !== "" && (st.opts?.authPassword ?? cfg.authPassword ?? "") !== ""
|
|
1741
|
+
if (wantedGate && frame.auth === false && !r.warnedNoGate) {
|
|
1742
|
+
r.warnedNoGate = true
|
|
1743
|
+
logError(`${r.url}: read auth requested but the page has NO gate - check the server's auth handling`)
|
|
1744
|
+
} else if (frame.auth === true) {
|
|
1745
|
+
r.warnedNoGate = false
|
|
1746
|
+
}
|
|
1747
|
+
// Wave 3 pmeta: machine-local facts (directory/worktree) ride their
|
|
1748
|
+
// OWN tiny sync once per init-ok, only to remotes we hold a
|
|
1749
|
+
// credential for - anonymous (ownerless) shares never store them.
|
|
1750
|
+
// Server-side they land in the private block, readable with share
|
|
1751
|
+
// auth. Idempotent upsert; rides no shared frame so fan-out
|
|
1752
|
+
// remotes with mixed identity each get exactly their due.
|
|
1753
|
+
if (credOf(r.url).credential) {
|
|
1754
|
+
const priv: Record<string, string> = {}
|
|
1755
|
+
const dir = (deps.directory ?? "") as string
|
|
1756
|
+
if (dir) priv.directory = dir
|
|
1757
|
+
if (deps.worktree) priv.worktree = String(deps.worktree)
|
|
1758
|
+
// the sharer's home lets the server mask absolute paths as ~/...
|
|
1759
|
+
try {
|
|
1760
|
+
priv.home = homedir()
|
|
1761
|
+
} catch {}
|
|
1762
|
+
if (Object.keys(priv).length) {
|
|
1763
|
+
const seq = ++nextSeq
|
|
1764
|
+
wsSend(r, { op: "sync", items: [{ type: "pmeta", sessionID: st.sid, data: priv }], status: undefined, seq })
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1502
1767
|
announceUrl(st, r)
|
|
1503
1768
|
// first binding write is immediate (the TUI waits on the URL in
|
|
1504
1769
|
// session metadata); ack-driven rev updates stay debounced
|
|
@@ -1515,12 +1780,12 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1515
1780
|
}
|
|
1516
1781
|
if (wasFanoutCreate) {
|
|
1517
1782
|
// a server joined an ALREADY-live session (fan-out add, or a
|
|
1518
|
-
// post-mismatch recreate): its fresh share needs the full history
|
|
1519
|
-
// backfill is keyed-idempotent
|
|
1520
|
-
// The enqueue dedupe must step aside: those hashes
|
|
1521
|
-
// entered the machinery", but the NEW remote
|
|
1522
|
-
// of it
|
|
1523
|
-
//
|
|
1783
|
+
// post-mismatch recreate): its fresh share needs the full history,
|
|
1784
|
+
// and backfill is keyed-idempotent while other servers simply
|
|
1785
|
+
// re-upsert. The enqueue dedupe must step aside here: those hashes
|
|
1786
|
+
// say "already entered the machinery", but the NEW remote received
|
|
1787
|
+
// none of it, so a deduped join backfills nothing and the joined
|
|
1788
|
+
// share stays empty.
|
|
1524
1789
|
st.sentHash.clear()
|
|
1525
1790
|
chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", (e as Error)?.message || e))
|
|
1526
1791
|
}
|
|
@@ -1625,8 +1890,8 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1625
1890
|
}
|
|
1626
1891
|
|
|
1627
1892
|
// returns a promise when the frame goes out via HTTP (the dispose path
|
|
1628
|
-
// awaits it
|
|
1629
|
-
// final status never lands,
|
|
1893
|
+
// awaits it: an unawaited fetch dies with the exiting process and the
|
|
1894
|
+
// final status never lands, leaving the share stuck on "live")
|
|
1630
1895
|
const flush = (st: SessionState): Promise<unknown> | undefined => {
|
|
1631
1896
|
if (st.closed) return
|
|
1632
1897
|
const f = frameFrom(st)
|
|
@@ -1634,8 +1899,8 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1634
1899
|
// fan out to every remote; each carries its own pending copy and acks
|
|
1635
1900
|
// independently. Remotes mid-handshake or auth-blocked just buffer.
|
|
1636
1901
|
// PENDING IS CHUNK-SHAPED (seq-tagged): one logical frame becomes N
|
|
1637
|
-
// chunk-frames so a partial
|
|
1638
|
-
//
|
|
1902
|
+
// chunk-frames, so a partial ack can never erase unsent chunks (a
|
|
1903
|
+
// chunk-1 ack followed by socket death used to drop chunks 2..N).
|
|
1639
1904
|
const chunks: Array<{ items: unknown[]; json: string }> = f.items.length ? chunkedFrames(f.items, 200, 512 * 1024) : []
|
|
1640
1905
|
let httpWork: Promise<unknown> | undefined
|
|
1641
1906
|
for (const r of st.remotes.values()) {
|
|
@@ -1659,8 +1924,8 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1659
1924
|
}
|
|
1660
1925
|
// never send syncs before the init ack: the worker's per-socket
|
|
1661
1926
|
// ControlDO has no attach until OUR init completes, and a racing
|
|
1662
|
-
// sync dies as "unknown op"
|
|
1663
|
-
//
|
|
1927
|
+
// sync dies as "unknown op", losing whole turns on post-drop
|
|
1928
|
+
// reconnects. The init-ok handler flushes pending itself.
|
|
1664
1929
|
if (r.ws && !r.wsReady) continue
|
|
1665
1930
|
if (r.wsReady) {
|
|
1666
1931
|
for (const cf of fresh) sendSync(r, cf)
|
|
@@ -1695,13 +1960,12 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1695
1960
|
|
|
1696
1961
|
const flushViaHttp = async (st: SessionState, r: RemoteState) => {
|
|
1697
1962
|
if (!r.shareId || !r.pending.length) return
|
|
1698
|
-
// ALL pending frames, merged (keyed upserts
|
|
1699
|
-
//
|
|
1700
|
-
//
|
|
1701
|
-
//
|
|
1702
|
-
//
|
|
1703
|
-
//
|
|
1704
|
-
// server row-write + a rebroadcast frame.
|
|
1963
|
+
// ALL pending frames, merged (keyed upserts, later wins). Sending only
|
|
1964
|
+
// the last frame dropped every earlier batch's items whenever the HTTP
|
|
1965
|
+
// fallback carried more than one, losing content on reconnect paths.
|
|
1966
|
+
// DEDUPE BY KEY before chunking: a streaming message appearing in k
|
|
1967
|
+
// pending frames shipped k copies (up to 25x on a replay burst), and
|
|
1968
|
+
// every duplicate cost a server row-write plus a rebroadcast frame.
|
|
1705
1969
|
const byKey = new Map<string, unknown>()
|
|
1706
1970
|
const unkeyed: unknown[] = []
|
|
1707
1971
|
let status: string | undefined
|
|
@@ -1768,15 +2032,19 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1768
2032
|
}, wait)
|
|
1769
2033
|
}
|
|
1770
2034
|
|
|
2035
|
+
const homeDir = (() => { try { return homedir() } catch { return "" } })()
|
|
2036
|
+
const userPatterns = (cfg as { redact?: { patterns?: unknown } }).redact?.patterns
|
|
2037
|
+
const redactionRules = activeRules(userPatterns)
|
|
2038
|
+
|
|
1771
2039
|
const enqueue = (st: SessionState, entry: ItemEntry | undefined) => {
|
|
1772
2040
|
if (!st || !entry) return
|
|
1773
2041
|
// content dedupe at the machinery boundary: an identical re-feed has
|
|
1774
|
-
// either entered the queue already or been flushed into every
|
|
1775
|
-
//
|
|
1776
|
-
//
|
|
1777
|
-
//
|
|
1778
|
-
//
|
|
1779
|
-
// dormant and
|
|
2042
|
+
// either entered the queue already or been flushed into every remote's
|
|
2043
|
+
// pending/spool, and the per-remote delivery machinery owns it from
|
|
2044
|
+
// there. Re-sending it bumps rev, rewrites storage and re-folds every
|
|
2045
|
+
// viewer for nothing. This is the engine-side home dedupe belongs in:
|
|
2046
|
+
// an adapter-side cache can be poisoned while the session is still
|
|
2047
|
+
// dormant, and then the flag-time content is skipped.
|
|
1780
2048
|
let h: string
|
|
1781
2049
|
try {
|
|
1782
2050
|
h = createHash("sha256").update(JSON.stringify(entry.item)).digest("hex")
|
|
@@ -1788,6 +2056,24 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1788
2056
|
return
|
|
1789
2057
|
}
|
|
1790
2058
|
if (h) st.sentHash.set(entry.key, h)
|
|
2059
|
+
// Wave 3 trust pass: redaction on UNGATED shares only (a gated share
|
|
2060
|
+
// is private from casual observers already; the server owner sees
|
|
2061
|
+
// content either way until encryption lands). The light path
|
|
2062
|
+
// transform applies regardless. Zero-copy when nothing matches.
|
|
2063
|
+
{
|
|
2064
|
+
const gate = authFrame(st)
|
|
2065
|
+
const gated = gate.authLogin !== "" && gate.authPassword !== ""
|
|
2066
|
+
const pass = redactItem(entry.item, { rules: redactionRules, home: homeDir, enabled: !gated })
|
|
2067
|
+
if (pass.item !== entry.item) entry = { ...entry, item: pass.item as typeof entry.item }
|
|
2068
|
+
if (pass.hits.length) {
|
|
2069
|
+
const led = redactionLedger.get(st.sid) ?? { count: 0, samples: [] as RedactionHit[] }
|
|
2070
|
+
led.count += pass.hits.length
|
|
2071
|
+
for (const hit of pass.hits) {
|
|
2072
|
+
if (led.samples.length < 5 && !led.samples.some((x) => x.kind === hit.kind)) led.samples.push(hit)
|
|
2073
|
+
}
|
|
2074
|
+
redactionLedger.set(st.sid, led)
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
1791
2077
|
st.queue.set(entry.key, entry.item)
|
|
1792
2078
|
indexMessage(entry)
|
|
1793
2079
|
if (debugEvents) log(`enqueue sid=${st.sid.slice(0, 18)} ${entry.item.type} queue=${st.queue.size}`)
|
|
@@ -1796,6 +2082,24 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1796
2082
|
scheduleFlush(st)
|
|
1797
2083
|
}
|
|
1798
2084
|
|
|
2085
|
+
// Wave 3 preflight counts: the module-level accessor delegates here
|
|
2086
|
+
statsProvider = (sid: string) => {
|
|
2087
|
+
const st = sessions.get(sid)
|
|
2088
|
+
let messages = 0
|
|
2089
|
+
let toolCalls = 0
|
|
2090
|
+
if (st) {
|
|
2091
|
+
for (const [, merged] of st.merged) {
|
|
2092
|
+
messages++
|
|
2093
|
+
const content = (merged as { content?: unknown }).content
|
|
2094
|
+
if (Array.isArray(content)) toolCalls += content.filter((c: unknown) => !!c && (c as { type?: string }).type === "tool").length
|
|
2095
|
+
}
|
|
2096
|
+
for (const it of st.queue.values()) {
|
|
2097
|
+
if ((it as { type?: string })?.type === "message") messages++
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
return { messages, toolCalls }
|
|
2101
|
+
}
|
|
2102
|
+
|
|
1799
2103
|
// ---- authoritative stamps ----------------------------------------------
|
|
1800
2104
|
// The viewer renders stamped facts and never re-derives them (owner
|
|
1801
2105
|
// directive). Both stamps are computed at frame time across the whole
|
|
@@ -1811,10 +2115,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1811
2115
|
}
|
|
1812
2116
|
const msgIndex = new Map<string, IndexedMessage>() // `${sid}:${messageID}`
|
|
1813
2117
|
/** message ids tombstoned this run: stampsPass must never re-upsert an
|
|
1814
|
-
* undone message
|
|
1815
|
-
*
|
|
1816
|
-
*
|
|
1817
|
-
* deleted turn server-side). */
|
|
2118
|
+
* undone message. dropMergedMessage removes the merged entry but msgIndex
|
|
2119
|
+
* keeps the corpse, so a neighboring-segment change recomputes its
|
|
2120
|
+
* stamps and reEnqueueMessage resurrects the deleted turn server-side. */
|
|
1818
2121
|
const tombstoned = new Set<string>()
|
|
1819
2122
|
|
|
1820
2123
|
const indexMessage = (entry: ItemEntry) => {
|
|
@@ -1877,11 +2180,11 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1877
2180
|
const agent = segmentAgent(list, users[k])
|
|
1878
2181
|
const d = list[users[k]].data as Record<string, any>
|
|
1879
2182
|
const want = k > 0 && agent && prev && agent !== prev ? agent : undefined
|
|
1880
|
-
// only touch metadata when the stamp actually CHANGES
|
|
1881
|
-
// `d.metadata ??= {}`
|
|
2183
|
+
// only touch metadata when the stamp actually CHANGES: a blanket
|
|
2184
|
+
// `d.metadata ??= {}` pollutes every user message (including the
|
|
1882
2185
|
// host's live objects on v2 pass-through) with an empty metadata
|
|
1883
|
-
// object, which re-
|
|
1884
|
-
// per feed
|
|
2186
|
+
// object, which re-emits the message once per refresh and churns
|
|
2187
|
+
// rev per feed.
|
|
1885
2188
|
if (want !== (d.metadata?.agentSwitch as string | undefined)) {
|
|
1886
2189
|
if (want) {
|
|
1887
2190
|
const meta = (d.metadata ??= {}) as Record<string, unknown>
|
|
@@ -1945,8 +2248,8 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1945
2248
|
}
|
|
1946
2249
|
|
|
1947
2250
|
/** file tools get metadata.language (shiki lang id, from the path the
|
|
1948
|
-
* call touched)
|
|
1949
|
-
*
|
|
2251
|
+
* call touched). The viewer highlights from this stamp and never
|
|
2252
|
+
* guesses; its extension map only covers old shares without one. */
|
|
1950
2253
|
const LANG_BY_EXT: Record<string, string> = {
|
|
1951
2254
|
ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript",
|
|
1952
2255
|
js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
|
|
@@ -1957,7 +2260,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1957
2260
|
sql: "sql", c: "c", h: "c", cpp: "cpp", hpp: "cpp", cs: "csharp",
|
|
1958
2261
|
swift: "swift", kt: "kotlin", php: "php", lua: "lua", vue: "vue", svelte: "svelte",
|
|
1959
2262
|
}
|
|
2263
|
+
|
|
1960
2264
|
const FILE_TOOLS = new Set(["read", "write", "edit", "patch"])
|
|
2265
|
+
|
|
1961
2266
|
const stampFileLanguages = () => {
|
|
1962
2267
|
for (const m of msgIndex.values()) {
|
|
1963
2268
|
if (m.role !== "assistant") continue
|
|
@@ -1970,6 +2275,10 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1970
2275
|
const state = part.state
|
|
1971
2276
|
const meta = state?.metadata
|
|
1972
2277
|
if (!meta) continue
|
|
2278
|
+
// paths stay absolute here: the server rewrites them against the
|
|
2279
|
+
// session's working folder on read (wireItems), so old shares get
|
|
2280
|
+
// the same treatment and the host's message objects are never
|
|
2281
|
+
// mutated
|
|
1973
2282
|
const raw = state.input?.path ?? state.input?.filePath
|
|
1974
2283
|
// language stamp (shiki id from the path)
|
|
1975
2284
|
if (typeof meta.language !== "string" && typeof raw === "string") {
|
|
@@ -2130,9 +2439,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2130
2439
|
r.idleDropTimer = null
|
|
2131
2440
|
}
|
|
2132
2441
|
// wsReady delete only when THIS remote still owns a share: a stray
|
|
2133
|
-
// child teardown clears shareId after adopting into the parent
|
|
2134
|
-
// the ControlDO's server-side attach would
|
|
2135
|
-
//
|
|
2442
|
+
// child teardown clears shareId after adopting into the parent, and
|
|
2443
|
+
// the ControlDO's server-side attach would then delete the very
|
|
2444
|
+
// share the parent just inherited.
|
|
2136
2445
|
if (r.wsReady && r.shareId) {
|
|
2137
2446
|
wsSend(r, { op: "delete" })
|
|
2138
2447
|
// backstop: the WS delete is fire-and-forget on the wire — a
|
|
@@ -2241,9 +2550,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2241
2550
|
const lastInfo = (last?.info ?? last) as Record<string, unknown> | undefined
|
|
2242
2551
|
const lastID = typeof lastInfo?.id === "string" ? lastInfo.id : undefined
|
|
2243
2552
|
// `before` is an OPAQUE cursor: base64url({id, time}) of the page
|
|
2244
|
-
// tail (message-v2.ts codec).
|
|
2245
|
-
//
|
|
2246
|
-
// 100-message page
|
|
2553
|
+
// tail (message-v2.ts codec). Hosts with cursor pagination 400 on a
|
|
2554
|
+
// bare message id, which truncates a v1 backfill to its first
|
|
2555
|
+
// 100-message page.
|
|
2247
2556
|
const lastTime = (lastInfo?.time as Record<string, unknown> | undefined)?.created
|
|
2248
2557
|
if (!lastID || typeof lastTime !== "number") break
|
|
2249
2558
|
const nextBefore = cursorFor(lastID, lastTime)
|
|
@@ -2260,7 +2569,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2260
2569
|
|
|
2261
2570
|
const backfillSession = async (st: SessionState, client: ClientApi, sid: string, info?: SessionInfo) => {
|
|
2262
2571
|
const sessionInfoValue = info ?? (await sessionInfo(client, sid))
|
|
2263
|
-
if (sessionInfoValue) enqueue(st, itemFor("session", redact(sessionInfoValue), sid))
|
|
2572
|
+
if (sessionInfoValue) enqueue(st, itemFor("session", stripLocalFacts(redact(sessionInfoValue)), sid))
|
|
2264
2573
|
|
|
2265
2574
|
let modelSeen = false
|
|
2266
2575
|
const consume = (messages: Message[]) => {
|
|
@@ -2308,7 +2617,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2308
2617
|
// nothing synced yet and no info in hand: the session item is the
|
|
2309
2618
|
// one thing a viewer always needs — fetch it explicitly
|
|
2310
2619
|
const fetched = await sessionInfo(client, st.sid)
|
|
2311
|
-
if (fetched) enqueue(st, itemFor("session", redact(fetched), st.sid))
|
|
2620
|
+
if (fetched) enqueue(st, itemFor("session", stripLocalFacts(redact(fetched)), st.sid))
|
|
2312
2621
|
}
|
|
2313
2622
|
|
|
2314
2623
|
// children (subagent sessions) share the parent's page
|
|
@@ -2400,11 +2709,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2400
2709
|
// activates anything on its own
|
|
2401
2710
|
const st = sessions.get(parent)
|
|
2402
2711
|
if (st?.activated && !st.closed) {
|
|
2403
|
-
if (
|
|
2404
|
-
st.prefsSent = true
|
|
2405
|
-
enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid))
|
|
2406
|
-
}
|
|
2407
|
-
if (info) enqueue(st, itemFor("session", redact(info), sid))
|
|
2712
|
+
if (info) enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid))
|
|
2408
2713
|
}
|
|
2409
2714
|
return
|
|
2410
2715
|
}
|
|
@@ -2413,21 +2718,12 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2413
2718
|
if (flag.enabled) {
|
|
2414
2719
|
if (st?.activated && !st.closed) {
|
|
2415
2720
|
// already sharing: opts updates apply, flag removal handled below
|
|
2416
|
-
if (flag.opts) st
|
|
2417
|
-
if (
|
|
2418
|
-
st.prefsSent = true
|
|
2419
|
-
enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid))
|
|
2420
|
-
}
|
|
2421
|
-
if (info) enqueue(st, itemFor("session", redact(info), sid))
|
|
2721
|
+
if (flag.opts) applyOpts(st, flag.opts)
|
|
2722
|
+
if (info) enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid))
|
|
2422
2723
|
if (t === "session.updated") chain = chain.then(() => refreshOptOuts(st)).catch(() => {})
|
|
2423
2724
|
return
|
|
2424
2725
|
}
|
|
2425
2726
|
activate(sid, flag.opts, info)
|
|
2426
|
-
const fresh = sessions.get(sid)
|
|
2427
|
-
if (fresh && !fresh.prefsSent) {
|
|
2428
|
-
fresh.prefsSent = true
|
|
2429
|
-
enqueue(fresh, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid))
|
|
2430
|
-
}
|
|
2431
2727
|
return
|
|
2432
2728
|
}
|
|
2433
2729
|
if (st?.activated && !st.closed && t === "session.updated") {
|
|
@@ -2483,17 +2779,14 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2483
2779
|
}
|
|
2484
2780
|
}
|
|
2485
2781
|
if (!st) return
|
|
2486
|
-
if (
|
|
2487
|
-
st.prefsSent = true
|
|
2488
|
-
enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid))
|
|
2489
|
-
}
|
|
2490
|
-
if (info) enqueue(st, itemFor("session", redact(info), sid))
|
|
2782
|
+
if (info) enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid))
|
|
2491
2783
|
if (t === "session.updated" && st.activated && !st.closed) {
|
|
2492
2784
|
chain = chain.then(() => refreshOptOuts(st)).catch(() => {})
|
|
2493
2785
|
}
|
|
2494
2786
|
}
|
|
2495
2787
|
|
|
2496
2788
|
return {
|
|
2789
|
+
|
|
2497
2790
|
mode,
|
|
2498
2791
|
config: () => cfg,
|
|
2499
2792
|
hooks: {
|
|
@@ -2516,12 +2809,12 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2516
2809
|
touch(st)
|
|
2517
2810
|
if (type === "busy") {
|
|
2518
2811
|
// work resumed: a pending completed flip is cancelled (opencode
|
|
2519
|
-
// idles between sequential automation prompts
|
|
2520
|
-
// "done"), a recovered retry is forgiven, share is live
|
|
2521
|
-
// The busy epoch is the completed-flip guard: only WORK
|
|
2522
|
-
// invalidates a pending settle
|
|
2523
|
-
//
|
|
2524
|
-
//
|
|
2812
|
+
// idles between sequential automation prompts, which is NOT
|
|
2813
|
+
// "done"), a recovered retry is forgiven, and the share is live
|
|
2814
|
+
// again. The busy epoch is the completed-flip guard: only WORK
|
|
2815
|
+
// invalidates a pending settle. v1.18 republishes the status/idle
|
|
2816
|
+
// pair on failure paths, and a repeated idle must NOT void it or
|
|
2817
|
+
// the share stays on live forever.
|
|
2525
2818
|
st.statusEpoch++
|
|
2526
2819
|
if (st.completedTimer) {
|
|
2527
2820
|
clearTimeout(st.completedTimer)
|
|
@@ -2534,8 +2827,8 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2534
2827
|
} else if (type === "idle") {
|
|
2535
2828
|
// opencode's idle is authoritative turn end (v1 holds busy
|
|
2536
2829
|
// through steps, tool runs and permission waits). CONTENT
|
|
2537
|
-
// flushes now
|
|
2538
|
-
// interval (
|
|
2830
|
+
// flushes now, because turn-end batches must not ride the sync
|
|
2831
|
+
// interval (subagent finals lagged one interval otherwise).
|
|
2539
2832
|
// Only the completed flip waits out completedDelayMs, guarded
|
|
2540
2833
|
// by the busy epoch: work resuming voids it, repeated idle
|
|
2541
2834
|
// publications do not. Dispose flushes a pending flip, so CI
|
|
@@ -2667,11 +2960,11 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2667
2960
|
const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, info.model)
|
|
2668
2961
|
enqueue(st, itemFor("model", [{ id: info.model.modelID, providerID: info.model.providerID, name: label }], sid))
|
|
2669
2962
|
}
|
|
2670
|
-
// an assistant message that DIED (finish:"error")
|
|
2671
|
-
// retries exhausted
|
|
2672
|
-
// message
|
|
2673
|
-
//
|
|
2674
|
-
//
|
|
2963
|
+
// an assistant message that DIED (finish:"error"), e.g. all
|
|
2964
|
+
// retries exhausted, surfaces as a 200 response with an errored
|
|
2965
|
+
// message rather than session.error. The share must still say
|
|
2966
|
+
// error. Works for v2-native messages too (finish lives on the
|
|
2967
|
+
// message itself).
|
|
2675
2968
|
if ((info as { finish?: string; role?: string })?.finish === "error") {
|
|
2676
2969
|
st.badSignal = true
|
|
2677
2970
|
}
|
|
@@ -2728,7 +3021,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2728
3021
|
const flushed = flush(st)
|
|
2729
3022
|
if (flushed && typeof (flushed as Promise<unknown>).then === "function") await flushed.catch(() => {})
|
|
2730
3023
|
// dispose with a mid-handshake socket: the deferred pending
|
|
2731
|
-
// would die with the process
|
|
3024
|
+
// would die with the process - land it over HTTP instead
|
|
2732
3025
|
for (const r of st.remotes.values()) {
|
|
2733
3026
|
if (!r.wsReady && r.shareId && r.pending.length) {
|
|
2734
3027
|
await flushViaHttp(st, r).catch(() => {})
|
|
@@ -2748,6 +3041,23 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2748
3041
|
st.metaTimer = null
|
|
2749
3042
|
await writeBinding(st).catch(() => {})
|
|
2750
3043
|
}
|
|
3044
|
+
// URL-at-exit barrier: a very short `opencode run` can exit before
|
|
3045
|
+
// the create handshake finishes, and CI reads the urlFile the
|
|
3046
|
+
// moment the process ends. Bounded 5s wait for every share that is
|
|
3047
|
+
// mid-handshake or not yet announced (a dead worker costs exactly
|
|
3048
|
+
// the cap; a healthy one is instant).
|
|
3049
|
+
const barrierStart = Date.now()
|
|
3050
|
+
while (Date.now() - barrierStart < 5000) {
|
|
3051
|
+
let pendingAnnounce = false
|
|
3052
|
+
for (const st of sessions.values()) {
|
|
3053
|
+
for (const r of st.remotes.values()) {
|
|
3054
|
+
if (r.authBlocked || r.collided) continue
|
|
3055
|
+
if ((r.shareId && !r.announced) || (!r.shareId && (r.ws || r.connecting))) pendingAnnounce = true
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
if (!pendingAnnounce) break
|
|
3059
|
+
await new Promise((res) => setTimeout(res, 100))
|
|
3060
|
+
}
|
|
2751
3061
|
for (const st of sessions.values()) {
|
|
2752
3062
|
for (const r of st.remotes.values()) {
|
|
2753
3063
|
try {
|