thinkpool-pair 0.7.358 → 0.7.359
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/account.mjs +3 -1
- package/bridge.mjs +288 -16
- package/package.json +3 -1
- package/scheduled-run-admission.mjs +364 -0
- package/scheduled-runs.mjs +268 -0
- package/service.mjs +10 -4
- package/session-store.mjs +117 -0
- package/thinkpool-capabilities.json +6 -4
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// Host-wide scheduled-run admission for account-served room bridge children.
|
|
2
|
+
//
|
|
3
|
+
// Every room bridge is a separate process, so a process-local count/window cannot
|
|
4
|
+
// enforce the laptop safety boundary. This ledger serializes admissions under
|
|
5
|
+
// TP_PAIR_ROOT. It stores only hashes and millisecond timestamps: never room codes,
|
|
6
|
+
// prompts, provider/model names, credentials, endpoints, or host paths.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
12
|
+
import { SPAWN } from './cross-terminal.mjs'
|
|
13
|
+
|
|
14
|
+
const VERSION = 1
|
|
15
|
+
const LEDGER_NAME = 'scheduled-run-admission.json'
|
|
16
|
+
const LOCK_NAME = '.scheduled-run-admission.lock'
|
|
17
|
+
const MAX_LEDGER_BYTES = 128 * 1024
|
|
18
|
+
const DEFAULT_LOCK_WAIT_MS = 1_000
|
|
19
|
+
const DEFAULT_STALE_LOCK_MS = 30_000
|
|
20
|
+
const DEFAULT_RETRY_MS = 12
|
|
21
|
+
const DEFAULT_MAX_LEASE_MS = 31 * 60 * 1_000
|
|
22
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,255}$/
|
|
23
|
+
|
|
24
|
+
const rootPath = (root) => path.resolve(root || process.env.TP_PAIR_ROOT || path.join(os.homedir(), '.thinkpool-pair'))
|
|
25
|
+
const pathsFor = (root) => {
|
|
26
|
+
const dir = rootPath(root)
|
|
27
|
+
return {
|
|
28
|
+
dir,
|
|
29
|
+
ledger: path.join(dir, LEDGER_NAME),
|
|
30
|
+
lock: path.join(dir, LOCK_NAME),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const digest = (value) => createHash('sha256').update(String(value)).digest('hex')
|
|
34
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
35
|
+
const finiteInt = (value) => Number.isSafeInteger(value) && value >= 0
|
|
36
|
+
const exactKeys = (value, expected) =>
|
|
37
|
+
value && typeof value === 'object' &&
|
|
38
|
+
Object.keys(value).sort().join('\0') === [...expected].sort().join('\0')
|
|
39
|
+
|
|
40
|
+
function normalizeLimits (limits = {}) {
|
|
41
|
+
const machineMax = Number.isSafeInteger(limits.machineMax) && limits.machineMax > 0 ? limits.machineMax : SPAWN.machineMax
|
|
42
|
+
const perWindowCap = Number.isSafeInteger(limits.perWindowCap) && limits.perWindowCap > 0 ? limits.perWindowCap : SPAWN.perWindowCap
|
|
43
|
+
const windowMs = Number.isSafeInteger(limits.windowMs) && limits.windowMs > 0 ? limits.windowMs : SPAWN.windowMs
|
|
44
|
+
const maxLeaseMs = Number.isSafeInteger(limits.maxLeaseMs) && limits.maxLeaseMs > 0 ? limits.maxLeaseMs : DEFAULT_MAX_LEASE_MS
|
|
45
|
+
return Object.freeze({ machineMax, perWindowCap, windowMs, maxLeaseMs })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function emptyLedger () {
|
|
49
|
+
return { version: VERSION, leases: [], spawnTimes: [] }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validLease (lease) {
|
|
53
|
+
return Boolean(exactKeys(lease, ['runKey', 'terminalKey', 'tokenHash', 'acquiredAt', 'expiresAt']) &&
|
|
54
|
+
/^[a-f0-9]{64}$/.test(lease.runKey) &&
|
|
55
|
+
/^[a-f0-9]{64}$/.test(lease.terminalKey) &&
|
|
56
|
+
/^[a-f0-9]{64}$/.test(lease.tokenHash) &&
|
|
57
|
+
finiteInt(lease.acquiredAt) &&
|
|
58
|
+
finiteInt(lease.expiresAt) &&
|
|
59
|
+
lease.expiresAt > lease.acquiredAt)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readLedger (file, limits) {
|
|
63
|
+
let fd = null
|
|
64
|
+
let raw
|
|
65
|
+
try {
|
|
66
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0))
|
|
67
|
+
const stat = fs.fstatSync(fd)
|
|
68
|
+
if (!stat.isFile() || stat.size > MAX_LEDGER_BYTES) throw new Error('invalid_admission_ledger')
|
|
69
|
+
raw = fs.readFileSync(fd, 'utf8')
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error?.code === 'ENOENT') return emptyLedger()
|
|
72
|
+
throw error
|
|
73
|
+
} finally {
|
|
74
|
+
if (fd != null) fs.closeSync(fd)
|
|
75
|
+
}
|
|
76
|
+
const parsed = JSON.parse(raw)
|
|
77
|
+
if (!exactKeys(parsed, ['version', 'leases', 'spawnTimes']) || parsed.version !== VERSION || !Array.isArray(parsed.leases) || !Array.isArray(parsed.spawnTimes)) throw new Error('invalid_admission_ledger')
|
|
78
|
+
// Generous parse bounds let a future lower configured cap prune an older
|
|
79
|
+
// snapshot, while still refusing attacker-sized or corrupted structures.
|
|
80
|
+
if (parsed.leases.length > Math.max(64, limits.machineMax * 4) || parsed.spawnTimes.length > Math.max(128, limits.perWindowCap * 8)) throw new Error('unbounded_admission_ledger')
|
|
81
|
+
if (!parsed.leases.every(validLease) || !parsed.spawnTimes.every(finiteInt)) throw new Error('invalid_admission_ledger')
|
|
82
|
+
if (new Set(parsed.leases.map((lease) => lease.runKey)).size !== parsed.leases.length ||
|
|
83
|
+
new Set(parsed.leases.map((lease) => lease.terminalKey)).size !== parsed.leases.length) throw new Error('duplicate_admission_ledger')
|
|
84
|
+
return parsed
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function pruneLedger (ledger, now, limits) {
|
|
88
|
+
// Never truncate still-active leases merely because a later process starts
|
|
89
|
+
// with a lower cap. Every active lease continues to consume capacity until
|
|
90
|
+
// exact release or expiry.
|
|
91
|
+
const leases = ledger.leases.filter((lease) => lease.expiresAt > now)
|
|
92
|
+
const spawnTimes = ledger.spawnTimes
|
|
93
|
+
.filter((at) => at > now - limits.windowMs && at <= now)
|
|
94
|
+
.sort((a, b) => a - b)
|
|
95
|
+
return {
|
|
96
|
+
ledger: { version: VERSION, leases, spawnTimes },
|
|
97
|
+
changed: leases.length !== ledger.leases.length || spawnTimes.length !== ledger.spawnTimes.length,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let tempSerial = 0
|
|
102
|
+
function atomicWriteLedger (file, ledger) {
|
|
103
|
+
const serialized = `${JSON.stringify(ledger)}\n`
|
|
104
|
+
if (Buffer.byteLength(serialized) > MAX_LEDGER_BYTES) throw new Error('unbounded_admission_ledger')
|
|
105
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp.${process.pid}.${Date.now()}.${tempSerial++}`)
|
|
106
|
+
let fd = null
|
|
107
|
+
try {
|
|
108
|
+
fd = fs.openSync(tmp, 'wx', 0o600)
|
|
109
|
+
const bytes = Buffer.from(serialized)
|
|
110
|
+
for (let offset = 0; offset < bytes.length;) {
|
|
111
|
+
const written = fs.writeSync(fd, bytes, offset, bytes.length - offset)
|
|
112
|
+
if (written <= 0) throw new Error('short_admission_write')
|
|
113
|
+
offset += written
|
|
114
|
+
}
|
|
115
|
+
fs.fsyncSync(fd)
|
|
116
|
+
fs.closeSync(fd)
|
|
117
|
+
fd = null
|
|
118
|
+
fs.renameSync(tmp, file)
|
|
119
|
+
fs.chmodSync(file, 0o600)
|
|
120
|
+
// Persist the directory entry where the host filesystem supports it.
|
|
121
|
+
let dirFd = null
|
|
122
|
+
try {
|
|
123
|
+
dirFd = fs.openSync(path.dirname(file), 'r')
|
|
124
|
+
fs.fsyncSync(dirFd)
|
|
125
|
+
} catch {
|
|
126
|
+
// Windows and some network filesystems reject directory fsync. The
|
|
127
|
+
// replacement file itself was still fsynced before the atomic rename.
|
|
128
|
+
} finally {
|
|
129
|
+
if (dirFd != null) fs.closeSync(dirFd)
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
try { if (fd != null) fs.closeSync(fd) } catch { /* best effort */ }
|
|
133
|
+
try { fs.rmSync(tmp, { force: true }) } catch { /* best effort */ }
|
|
134
|
+
throw error
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function ensureRoot (dir) {
|
|
139
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
140
|
+
const stat = fs.lstatSync(dir)
|
|
141
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('invalid_pair_root')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function readLockOwner (lock) {
|
|
145
|
+
const file = path.join(lock, 'owner')
|
|
146
|
+
const stat = fs.lstatSync(file)
|
|
147
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1_024) return null
|
|
148
|
+
const owner = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
149
|
+
if (!exactKeys(owner, ['pid', 'token', 'createdAt']) ||
|
|
150
|
+
!Number.isSafeInteger(owner.pid) || owner.pid < 1 ||
|
|
151
|
+
typeof owner.token !== 'string' || !/^[a-f0-9-]{36}$/.test(owner.token) ||
|
|
152
|
+
!finiteInt(owner.createdAt)) return null
|
|
153
|
+
return owner
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function ownerProcessState (pid) {
|
|
157
|
+
try {
|
|
158
|
+
process.kill(pid, 0)
|
|
159
|
+
return 'live'
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error?.code === 'ESRCH') return 'dead'
|
|
162
|
+
// EPERM and every unknown platform error fail closed.
|
|
163
|
+
return 'unknown'
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function recoverStaleLock (lock, staleLockMs, now) {
|
|
168
|
+
const stat = fs.lstatSync(lock)
|
|
169
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || now - stat.mtimeMs <= staleLockMs) return false
|
|
170
|
+
let owner = null
|
|
171
|
+
try { owner = readLockOwner(lock) } catch { return false }
|
|
172
|
+
if (!owner || ownerProcessState(owner.pid) !== 'dead') return false
|
|
173
|
+
// Rename is the recovery claim. Only the process that atomically moves this
|
|
174
|
+
// exact stale directory may remove it; contenders see ENOENT and retry. A
|
|
175
|
+
// live, permission-unknown, or malformed owner is never stolen.
|
|
176
|
+
const stale = `${lock}.stale.${process.pid}.${randomUUID()}`
|
|
177
|
+
fs.renameSync(lock, stale)
|
|
178
|
+
fs.rmSync(stale, { recursive: true, force: true })
|
|
179
|
+
return true
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function acquireLock ({ dir, lock }, { lockWaitMs = DEFAULT_LOCK_WAIT_MS, staleLockMs = DEFAULT_STALE_LOCK_MS, retryMs = DEFAULT_RETRY_MS } = {}) {
|
|
183
|
+
ensureRoot(dir)
|
|
184
|
+
const startedAt = Date.now()
|
|
185
|
+
while (Date.now() - startedAt <= lockWaitMs) {
|
|
186
|
+
const owner = Object.freeze({ pid: process.pid, token: randomUUID(), createdAt: Date.now() })
|
|
187
|
+
try {
|
|
188
|
+
fs.mkdirSync(lock, { mode: 0o700 })
|
|
189
|
+
fs.writeFileSync(path.join(lock, 'owner'), JSON.stringify(owner), { flag: 'wx', mode: 0o600 })
|
|
190
|
+
return owner
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error?.code !== 'EEXIST') {
|
|
193
|
+
// If mkdir succeeded but writing the ownership marker failed, remove
|
|
194
|
+
// only the markerless lock we just created.
|
|
195
|
+
try {
|
|
196
|
+
if (fs.existsSync(lock) && !fs.existsSync(path.join(lock, 'owner'))) fs.rmdirSync(lock)
|
|
197
|
+
} catch { /* a contender/stale recovery may own the path now */ }
|
|
198
|
+
throw error
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
if (recoverStaleLock(lock, staleLockMs, Date.now())) continue
|
|
202
|
+
} catch (recoveryError) {
|
|
203
|
+
if (!['ENOENT', 'EEXIST'].includes(recoveryError?.code)) throw recoveryError
|
|
204
|
+
}
|
|
205
|
+
await sleep(Math.max(1, retryMs))
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
throw new Error('admission_lock_timeout')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function withLedgerLock (options, operation) {
|
|
212
|
+
const paths = pathsFor(options?.root)
|
|
213
|
+
let lockOwner = null
|
|
214
|
+
try {
|
|
215
|
+
lockOwner = await acquireLock(paths, options)
|
|
216
|
+
return await operation(paths)
|
|
217
|
+
} finally {
|
|
218
|
+
if (lockOwner) {
|
|
219
|
+
try {
|
|
220
|
+
// A paused process may resume after its stale lock was recovered. Never
|
|
221
|
+
// let that old holder remove the new holder's lock.
|
|
222
|
+
const ownerFile = path.join(paths.lock, 'owner')
|
|
223
|
+
const currentOwner = readLockOwner(paths.lock)
|
|
224
|
+
if (currentOwner?.pid === lockOwner.pid && currentOwner?.token === lockOwner.token) {
|
|
225
|
+
fs.rmSync(ownerFile, { force: true })
|
|
226
|
+
fs.rmdirSync(paths.lock)
|
|
227
|
+
}
|
|
228
|
+
} catch { /* stale recovery/host cleanup owns it */ }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function invalidIdentity (runId, terminalId) {
|
|
234
|
+
return typeof runId !== 'string' || !SAFE_ID.test(runId) || typeof terminalId !== 'string' || !SAFE_ID.test(terminalId)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Acquire before the database claim and local spawn. `runId` may be the
|
|
238
|
+
// deterministic occurrence key (schedule-id + next-run-at) because the database
|
|
239
|
+
// UUID does not exist until claim_due_code_schedule succeeds.
|
|
240
|
+
export async function acquireScheduledRunAdmission ({
|
|
241
|
+
runId,
|
|
242
|
+
terminalId,
|
|
243
|
+
deadlineAt,
|
|
244
|
+
now = Date.now(),
|
|
245
|
+
root,
|
|
246
|
+
limits,
|
|
247
|
+
lockWaitMs,
|
|
248
|
+
staleLockMs,
|
|
249
|
+
retryMs,
|
|
250
|
+
} = {}) {
|
|
251
|
+
const policy = normalizeLimits(limits)
|
|
252
|
+
if (invalidIdentity(runId, terminalId) || !finiteInt(now) || !finiteInt(deadlineAt) || deadlineAt <= now || deadlineAt - now > policy.maxLeaseMs) {
|
|
253
|
+
return Object.freeze({ ok: false, code: 'invalid_admission_request' })
|
|
254
|
+
}
|
|
255
|
+
const token = randomUUID()
|
|
256
|
+
const runKey = digest(runId)
|
|
257
|
+
const terminalKey = digest(terminalId)
|
|
258
|
+
try {
|
|
259
|
+
return await withLedgerLock({ root, lockWaitMs, staleLockMs, retryMs }, ({ ledger: file }) => {
|
|
260
|
+
const loaded = readLedger(file, policy)
|
|
261
|
+
const { ledger, changed } = pruneLedger(loaded, now, policy)
|
|
262
|
+
const sameRun = ledger.leases.find((lease) => lease.runKey === runKey)
|
|
263
|
+
const sameTerminal = ledger.leases.find((lease) => lease.terminalKey === terminalKey)
|
|
264
|
+
if (sameRun || sameTerminal) {
|
|
265
|
+
if (changed) atomicWriteLedger(file, ledger)
|
|
266
|
+
return Object.freeze({
|
|
267
|
+
ok: false,
|
|
268
|
+
code: sameRun && sameTerminal && sameRun === sameTerminal
|
|
269
|
+
? 'duplicate_lease'
|
|
270
|
+
: sameRun ? 'duplicate_run' : 'duplicate_terminal',
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
if (ledger.leases.length >= policy.machineMax) {
|
|
274
|
+
if (changed) atomicWriteLedger(file, ledger)
|
|
275
|
+
return Object.freeze({ ok: false, code: 'machine_cap' })
|
|
276
|
+
}
|
|
277
|
+
if (ledger.spawnTimes.length >= policy.perWindowCap) {
|
|
278
|
+
if (changed) atomicWriteLedger(file, ledger)
|
|
279
|
+
return Object.freeze({ ok: false, code: 'burst_cap', retryAt: ledger.spawnTimes[0] + policy.windowMs })
|
|
280
|
+
}
|
|
281
|
+
ledger.leases.push({ runKey, terminalKey, tokenHash: digest(token), acquiredAt: now, expiresAt: deadlineAt })
|
|
282
|
+
ledger.spawnTimes.push(now)
|
|
283
|
+
atomicWriteLedger(file, ledger)
|
|
284
|
+
return Object.freeze({
|
|
285
|
+
ok: true,
|
|
286
|
+
code: 'admitted',
|
|
287
|
+
lease: Object.freeze({ runId, terminalId, token, expiresAt: deadlineAt }),
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
} catch {
|
|
291
|
+
return Object.freeze({ ok: false, code: 'admission_unavailable' })
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Exact-pair + one-time token release makes outcome/close cleanup idempotent
|
|
296
|
+
// without allowing one room child to free another child's active lease.
|
|
297
|
+
export async function releaseScheduledRunAdmission ({
|
|
298
|
+
runId,
|
|
299
|
+
terminalId,
|
|
300
|
+
token,
|
|
301
|
+
now = Date.now(),
|
|
302
|
+
root,
|
|
303
|
+
limits,
|
|
304
|
+
lockWaitMs,
|
|
305
|
+
staleLockMs,
|
|
306
|
+
retryMs,
|
|
307
|
+
} = {}) {
|
|
308
|
+
const policy = normalizeLimits(limits)
|
|
309
|
+
if (invalidIdentity(runId, terminalId) || typeof token !== 'string' || token.length < 16 || token.length > 128 || !finiteInt(now)) {
|
|
310
|
+
return Object.freeze({ ok: false, code: 'invalid_release_request' })
|
|
311
|
+
}
|
|
312
|
+
const runKey = digest(runId)
|
|
313
|
+
const terminalKey = digest(terminalId)
|
|
314
|
+
const tokenHash = digest(token)
|
|
315
|
+
try {
|
|
316
|
+
return await withLedgerLock({ root, lockWaitMs, staleLockMs, retryMs }, ({ ledger: file }) => {
|
|
317
|
+
const loaded = readLedger(file, policy)
|
|
318
|
+
const { ledger, changed } = pruneLedger(loaded, now, policy)
|
|
319
|
+
const index = ledger.leases.findIndex((lease) => lease.runKey === runKey && lease.terminalKey === terminalKey)
|
|
320
|
+
if (index < 0) {
|
|
321
|
+
if (ledger.leases.some((lease) => lease.runKey === runKey || lease.terminalKey === terminalKey)) {
|
|
322
|
+
return Object.freeze({ ok: false, code: 'lease_mismatch' })
|
|
323
|
+
}
|
|
324
|
+
if (changed) atomicWriteLedger(file, ledger)
|
|
325
|
+
return Object.freeze({ ok: true, code: 'already_released' })
|
|
326
|
+
}
|
|
327
|
+
if (ledger.leases[index].tokenHash !== tokenHash) return Object.freeze({ ok: false, code: 'lease_mismatch' })
|
|
328
|
+
ledger.leases.splice(index, 1)
|
|
329
|
+
atomicWriteLedger(file, ledger)
|
|
330
|
+
return Object.freeze({ ok: true, code: 'released' })
|
|
331
|
+
})
|
|
332
|
+
} catch {
|
|
333
|
+
return Object.freeze({ ok: false, code: 'admission_unavailable' })
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Secret-free diagnostics/tests: counts and expiry only.
|
|
338
|
+
export async function inspectScheduledRunAdmissions ({
|
|
339
|
+
now = Date.now(),
|
|
340
|
+
root,
|
|
341
|
+
limits,
|
|
342
|
+
lockWaitMs,
|
|
343
|
+
staleLockMs,
|
|
344
|
+
retryMs,
|
|
345
|
+
} = {}) {
|
|
346
|
+
const policy = normalizeLimits(limits)
|
|
347
|
+
if (!finiteInt(now)) return Object.freeze({ ok: false, code: 'invalid_inspection_request' })
|
|
348
|
+
try {
|
|
349
|
+
return await withLedgerLock({ root, lockWaitMs, staleLockMs, retryMs }, ({ ledger: file }) => {
|
|
350
|
+
const loaded = readLedger(file, policy)
|
|
351
|
+
const { ledger, changed } = pruneLedger(loaded, now, policy)
|
|
352
|
+
if (changed) atomicWriteLedger(file, ledger)
|
|
353
|
+
return Object.freeze({
|
|
354
|
+
ok: true,
|
|
355
|
+
code: 'inspected',
|
|
356
|
+
active: ledger.leases.length,
|
|
357
|
+
burst: ledger.spawnTimes.length,
|
|
358
|
+
nextExpiryAt: ledger.leases.length ? Math.min(...ledger.leases.map((lease) => lease.expiresAt)) : null,
|
|
359
|
+
})
|
|
360
|
+
})
|
|
361
|
+
} catch {
|
|
362
|
+
return Object.freeze({ ok: false, code: 'admission_unavailable' })
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Durable, bridge-local execution for the deliberately small unattended-run V1.
|
|
2
|
+
// This module owns no credentials and never executes a provider itself: it admits a
|
|
3
|
+
// pre-bound schedule, asks the database to fence one occurrence, then lets bridge.mjs
|
|
4
|
+
// open an ordinary visible top-level terminal.
|
|
5
|
+
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { SPAWN, spawnDecision } from './cross-terminal.mjs'
|
|
8
|
+
|
|
9
|
+
const RUN_KINDS = new Set(['once', 'daily', 'weekly'])
|
|
10
|
+
const RUNTIMES = new Set(['claude', 'codex', 'hermes'])
|
|
11
|
+
const OUTCOMES = new Set(['delivered', 'needs_decision', 'failed', 'canceled'])
|
|
12
|
+
const REF_TYPES = new Set(['artifact', 'preview', 'commit', 'test', 'flow_task', 'control_item'])
|
|
13
|
+
const SECRET = /(?:sk-[a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|AIza[a-z0-9_-]{8,}|gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|xox[baprs]-[a-z0-9-]{10,}|AKIA[0-9A-Z]{16}|sbp_[a-z0-9]{20,}|eyJ[a-z0-9_-]{16,}\.[a-z0-9_-]{16,}\.[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,}|(?:token|secret|api[_-]?key|authorization)\s*=)/i
|
|
14
|
+
const HOST_PATH = /(?:^|\s)(?:~\/|\/Users\/|\/home\/|\/private\/|\/tmp\/|[A-Za-z]:\\|\.\.[\\/])/i
|
|
15
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/
|
|
16
|
+
const OUTCOME_CODE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/
|
|
17
|
+
const formatterCache = new Map()
|
|
18
|
+
|
|
19
|
+
// This is deliberately opt-in. A packaged bridge with schedules in its database
|
|
20
|
+
// remains inert unless its owner explicitly enables unattended execution.
|
|
21
|
+
export const scheduledRunsEnabled = (value = process.env.TP_SCHEDULED_RUNS_ENABLED) => value === '1'
|
|
22
|
+
// Secret-free room projection. Clients need only the host owner's opt-in state,
|
|
23
|
+
// never the scheduler's authentication, lease/fencing, or execution internals.
|
|
24
|
+
export const scheduledRunsCapability = (value = process.env.TP_SCHEDULED_RUNS_ENABLED) => Object.freeze({
|
|
25
|
+
enabled: scheduledRunsEnabled(value),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const text = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max && !SECRET.test(value) && !HOST_PATH.test(value)
|
|
29
|
+
const date = (value) => value instanceof Date ? value : new Date(value)
|
|
30
|
+
const validDate = (value) => !Number.isNaN(date(value).getTime())
|
|
31
|
+
const timeParts = (zone, at) => {
|
|
32
|
+
let formatter = formatterCache.get(zone)
|
|
33
|
+
if (!formatter) {
|
|
34
|
+
formatter = new Intl.DateTimeFormat('en-CA', { timeZone: zone, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
|
35
|
+
formatterCache.set(zone, formatter)
|
|
36
|
+
}
|
|
37
|
+
const parts = Object.fromEntries(formatter.formatToParts(at).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]))
|
|
38
|
+
return { weekday: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(parts.weekday), time: `${parts.hour}:${parts.minute}` }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function scheduleValidationError (input = {}) {
|
|
42
|
+
if (!input || typeof input !== 'object' || !RUN_KINDS.has(input.kind)) return 'invalid_kind'
|
|
43
|
+
if (!text(input.title, 120) || !text(input.prompt, 12_000)) return 'unsafe_or_invalid_text'
|
|
44
|
+
if (!RUNTIMES.has(input.runtime)) return 'invalid_runtime'
|
|
45
|
+
if (input.provider != null && (!text(input.provider, 80) || !SAFE_ID.test(input.provider))) return 'invalid_provider'
|
|
46
|
+
if (input.model != null && (!text(input.model, 160) || !SAFE_ID.test(input.model))) return 'invalid_model'
|
|
47
|
+
if (!Number.isInteger(input.maxRuntimeMinutes) || input.maxRuntimeMinutes < 1 || input.maxRuntimeMinutes > 30) return 'invalid_max_runtime'
|
|
48
|
+
try { new Intl.DateTimeFormat('en', { timeZone: input.timezone }) } catch { return 'invalid_timezone' }
|
|
49
|
+
if (input.kind === 'once' && !validDate(input.at)) return 'invalid_once_time'
|
|
50
|
+
if (input.kind !== 'once' && !/^\d{2}:\d{2}$/.test(input.time) || input.kind !== 'once' && (Number(input.time.slice(0, 2)) > 23 || Number(input.time.slice(3)) > 59)) return 'invalid_local_time'
|
|
51
|
+
if (input.kind === 'weekly' && (!Number.isInteger(input.weekday) || input.weekday < 0 || input.weekday > 6)) return 'invalid_weekday'
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function normalizeSchedule (input = {}, now = new Date()) {
|
|
56
|
+
const error = scheduleValidationError(input)
|
|
57
|
+
if (error) return Object.freeze({ ok: false, code: error })
|
|
58
|
+
const normalized = {
|
|
59
|
+
kind: input.kind, title: input.title.trim(), prompt: input.prompt.trim(), runtime: input.runtime,
|
|
60
|
+
provider: input.provider || null, model: input.model || null, timezone: input.timezone,
|
|
61
|
+
maxRuntimeMinutes: input.maxRuntimeMinutes, ...(input.kind === 'once' ? { at: date(input.at).toISOString() } : { time: input.time, ...(input.kind === 'weekly' ? { weekday: input.weekday } : {}) }),
|
|
62
|
+
}
|
|
63
|
+
const nextRunAt = nextScheduledAt(normalized, now)
|
|
64
|
+
if (!nextRunAt) return Object.freeze({ ok: false, code: 'schedule_exhausted' })
|
|
65
|
+
return Object.freeze({ ok: true, value: Object.freeze(normalized), nextRunAt: nextRunAt.toISOString() })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Search minute boundaries in local calendar time. This is intentionally a small,
|
|
69
|
+
// deterministic preset scheduler: nonexistent DST minutes are skipped, repeated minutes
|
|
70
|
+
// choose the first future instant, and raw cron is not accepted anywhere.
|
|
71
|
+
export function nextScheduledAt (schedule, after = new Date()) {
|
|
72
|
+
if (schedule?.kind === 'once') {
|
|
73
|
+
const once = date(schedule.at)
|
|
74
|
+
return validDate(once) && once.getTime() > date(after).getTime() ? once : null
|
|
75
|
+
}
|
|
76
|
+
const start = Math.floor(date(after).getTime() / 60_000) * 60_000 + 60_000
|
|
77
|
+
for (let ms = start, end = start + 400 * 86_400_000; ms < end; ms += 60_000) {
|
|
78
|
+
const local = timeParts(schedule.timezone, new Date(ms))
|
|
79
|
+
if (local.time === schedule.time && (schedule.kind === 'daily' || local.weekday === schedule.weekday)) return new Date(ms)
|
|
80
|
+
}
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function classifyRunOutcome ({ result = null, evidenceRefs = [], pendingControls = [], runId = null, canceled = false } = {}) {
|
|
85
|
+
// A hard runtime ceiling is the terminal outcome even if the aborted runtime
|
|
86
|
+
// still has a pending permission or produced earlier evidence.
|
|
87
|
+
if (result?.subtype === 'runtime_limit') return Object.freeze({ outcome: 'failed', code: 'runtime_limit' })
|
|
88
|
+
if (canceled || ['aborted', 'canceled', 'cancelled', 'interrupted'].includes(result?.subtype)) return Object.freeze({ outcome: 'canceled', code: 'canceled' })
|
|
89
|
+
const evidence = (Array.isArray(evidenceRefs) ? evidenceRefs : []).filter((ref) => ref && REF_TYPES.has(ref.type) && typeof ref.id === 'string' && SAFE_ID.test(ref.id)).slice(0, 16)
|
|
90
|
+
if (evidence.length) return Object.freeze({ outcome: 'delivered', code: 'evidence_delivered', evidence })
|
|
91
|
+
const pending = (Array.isArray(pendingControls) ? pendingControls : []).find((item) =>
|
|
92
|
+
item?.status === 'pending' && item?.id && item?.request_context?.run_id &&
|
|
93
|
+
(!runId || item.request_context.run_id === runId) &&
|
|
94
|
+
Number.isSafeInteger(Number(item.source_event_from_seq)))
|
|
95
|
+
if (pending) return Object.freeze({ outcome: 'needs_decision', code: 'pending_pair_control', controlItemId: pending.id })
|
|
96
|
+
if (result?.subtype === 'success') return Object.freeze({ outcome: 'failed', code: 'missing_deliverable' })
|
|
97
|
+
return Object.freeze({ outcome: 'failed', code: result?.subtype || 'runtime_failed' })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const sanitizedEvidence = (refs) => (Array.isArray(refs) ? refs : [])
|
|
101
|
+
.filter((ref) => ref && REF_TYPES.has(ref.type) && typeof ref.id === 'string' && SAFE_ID.test(ref.id))
|
|
102
|
+
.slice(0, 16)
|
|
103
|
+
.map((ref) => Object.freeze({ type: ref.type, id: ref.id }))
|
|
104
|
+
|
|
105
|
+
// The host outbox persists only the minimum replayable intent. Never persist
|
|
106
|
+
// result text, permission input, prompts, credentials, endpoints, or host paths.
|
|
107
|
+
export function sanitizeScheduledOutcomeIntent ({ runId, result = null, evidenceRefs = [], canceled = false } = {}) {
|
|
108
|
+
if (typeof runId !== 'string' || !SAFE_ID.test(runId)) return null
|
|
109
|
+
const subtype = typeof result?.subtype === 'string' && OUTCOME_CODE.test(result.subtype) ? result.subtype : 'runtime_failed'
|
|
110
|
+
return Object.freeze({
|
|
111
|
+
runId,
|
|
112
|
+
result: Object.freeze({ subtype }),
|
|
113
|
+
evidenceRefs: Object.freeze(sanitizedEvidence(evidenceRefs)),
|
|
114
|
+
canceled: canceled === true,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A restored result/decision outbox owns the occurrence until its idempotent
|
|
119
|
+
// database finish is acknowledged. An elapsed runtime deadline must not replace
|
|
120
|
+
// that already-chosen outcome, and the scheduler must not expire occurrences
|
|
121
|
+
// while any such durable intent is still awaiting reconciliation.
|
|
122
|
+
export function scheduledRunDeadlineRequired ({ runId, deadlineAt, outcomeRecorded = false, outcomePending = null } = {}) {
|
|
123
|
+
return Boolean(runId && Number.isFinite(deadlineAt) && !outcomeRecorded && !outcomePending)
|
|
124
|
+
}
|
|
125
|
+
export function scheduledOutcomeReconciliationComplete (pendingOutcomes = []) {
|
|
126
|
+
return Array.isArray(pendingOutcomes) && pendingOutcomes.length === 0
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Scheduled runs share the ordinary spawn kill-switch, machine cap, and burst
|
|
130
|
+
// window, but are not Ensemble dispatches and therefore never consume a plan
|
|
131
|
+
// quota. The returned window is pruned and bounded; callers persist it only when
|
|
132
|
+
// ok=true.
|
|
133
|
+
export function scheduledRunSpawnAdmission ({ spawnTimes = [], now = Date.now(), totalLive = 0, disabled = false } = {}, limits = SPAWN) {
|
|
134
|
+
const o = { ...SPAWN, ...(limits || {}) }
|
|
135
|
+
const recent = (Array.isArray(spawnTimes) ? spawnTimes : [])
|
|
136
|
+
.filter((value) => Number.isFinite(value) && value > now - o.windowMs)
|
|
137
|
+
.slice(-(o.perWindowCap || 1))
|
|
138
|
+
const gate = spawnDecision({
|
|
139
|
+
hop: 0,
|
|
140
|
+
spawnTimes: recent,
|
|
141
|
+
now,
|
|
142
|
+
spawnedLive: 0,
|
|
143
|
+
totalLive,
|
|
144
|
+
plan: 'free',
|
|
145
|
+
disabled,
|
|
146
|
+
}, o)
|
|
147
|
+
return Object.freeze({
|
|
148
|
+
...gate,
|
|
149
|
+
spawnTimes: Object.freeze(gate.ok ? [...recent, now].slice(-o.perWindowCap) : recent),
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function preflightScheduledRun ({ schedule, occurrence, ownerId, bridgeId, bridgeFencing, providerAvailable, capsOk = true, now = new Date() } = {}) {
|
|
154
|
+
if (!schedule?.enabled) return Object.freeze({ ok: false, code: 'schedule_disabled' })
|
|
155
|
+
if (!occurrence?.id || occurrence.schedule_revision !== schedule.revision) return Object.freeze({ ok: false, code: 'stale_schedule_revision' })
|
|
156
|
+
if (!ownerId || !bridgeId || !Number.isSafeInteger(bridgeFencing) || bridgeFencing < 1 || occurrence.owner_id !== ownerId || occurrence.bridge_id !== bridgeId || occurrence.bridge_fencing !== bridgeFencing) return Object.freeze({ ok: false, code: 'owner_bridge_mismatch' })
|
|
157
|
+
if (!capsOk) return Object.freeze({ ok: false, code: 'runtime_cap_exceeded' })
|
|
158
|
+
if (typeof providerAvailable !== 'function' || !providerAvailable(schedule)) return Object.freeze({ ok: false, code: 'provider_unavailable' })
|
|
159
|
+
if (!occurrence.lease_expires_at || date(occurrence.lease_expires_at).getTime() <= date(now).getTime()) return Object.freeze({ ok: false, code: 'lease_expired' })
|
|
160
|
+
return Object.freeze({ ok: true, code: 'admitted' })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const rpc = async ({ fetchImpl, supabaseUrl, headers, name, body }) => {
|
|
164
|
+
const response = await fetchImpl(`${String(supabaseUrl).replace(/\/$/, '')}/rest/v1/rpc/${name}`, { method: 'POST', headers, body: JSON.stringify(body) })
|
|
165
|
+
if (!response?.ok) throw new Error(`${name}_failed`)
|
|
166
|
+
return response.json()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function pollDueScheduledRuns ({ enabled = false, fetchImpl = globalThis.fetch, supabaseUrl, anonKey, token, roomCode, bridgeId, bridgeFencing, ownerId, providerAvailable, openTerminal, admitRun, releaseRun, makeTerminalId = randomUUID, capsOk = () => true, now = new Date() } = {}) {
|
|
170
|
+
if (!enabled) return Object.freeze({ ok: false, code: 'feature_disabled', runs: [] })
|
|
171
|
+
if (typeof fetchImpl !== 'function' || !supabaseUrl || !anonKey || !token || !roomCode || !bridgeId || !Number.isSafeInteger(bridgeFencing) || bridgeFencing < 1 || !ownerId || typeof openTerminal !== 'function' || typeof admitRun !== 'function' || typeof releaseRun !== 'function' || typeof makeTerminalId !== 'function') return Object.freeze({ ok: false, code: 'runner_unavailable', runs: [] })
|
|
172
|
+
const headers = { apikey: anonKey, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
|
|
173
|
+
const normalizedRoomCode = String(roomCode).trim().toUpperCase()
|
|
174
|
+
try {
|
|
175
|
+
// An opened row can outlive its local terminal if the room process dies in
|
|
176
|
+
// the durable-open/local-open gap. Once its lease (runtime ceiling + grace)
|
|
177
|
+
// expires, turn that ambiguity into one explicit failure instead of leaving
|
|
178
|
+
// the occurrence permanently "running".
|
|
179
|
+
const expired = await rpc({ fetchImpl, supabaseUrl, headers, name: 'expire_code_scheduled_runs', body: {
|
|
180
|
+
p_room_code: normalizedRoomCode, p_bridge_id: bridgeId, p_bridge_fencing: bridgeFencing, p_limit: 24,
|
|
181
|
+
} })
|
|
182
|
+
if (!expired?.ok) throw new Error('expire_code_scheduled_runs_failed')
|
|
183
|
+
const due = await rpc({ fetchImpl, supabaseUrl, headers, name: 'list_due_code_schedules', body: {
|
|
184
|
+
p_room_code: normalizedRoomCode, p_limit: 12,
|
|
185
|
+
} })
|
|
186
|
+
const runs = []
|
|
187
|
+
for (const schedule of Array.isArray(due) ? due : []) {
|
|
188
|
+
if (String(schedule?.session_code || '').trim().toUpperCase() !== normalizedRoomCode) {
|
|
189
|
+
runs.push({ scheduleId: schedule?.id, code: 'room_scope_mismatch' })
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
if (!capsOk(schedule)) { runs.push({ scheduleId: schedule.id, code: 'runtime_cap_exceeded' }); continue }
|
|
193
|
+
if (typeof providerAvailable !== 'function' || !providerAvailable(schedule)) { runs.push({ scheduleId: schedule.id, code: 'provider_unavailable' }); continue }
|
|
194
|
+
const terminalId = makeTerminalId()
|
|
195
|
+
if (typeof terminalId !== 'string' || !SAFE_ID.test(terminalId)) { runs.push({ scheduleId: schedule.id, code: 'terminal_id_unavailable' }); continue }
|
|
196
|
+
const definition = { ...(schedule.schedule_spec || {}), kind: schedule.schedule_kind || schedule.kind, timezone: schedule.timezone }
|
|
197
|
+
const next = definition.kind === 'once' ? new Date('9999-12-31T23:59:00.000Z') : nextScheduledAt(definition, new Date(schedule.next_run_at))
|
|
198
|
+
if (!next) { runs.push({ scheduleId: schedule.id, code: 'next_run_invalid' }); continue }
|
|
199
|
+
const occurrenceKey = `${schedule.id}:${schedule.next_run_at}`
|
|
200
|
+
const leaseSeconds = Math.min(1_860, Math.max(60, schedule.max_runtime_minutes * 60 + 60))
|
|
201
|
+
const deadlineAt = date(now).getTime() + leaseSeconds * 1000
|
|
202
|
+
let admission = null
|
|
203
|
+
try { admission = await admitRun({ schedule, runId: occurrenceKey, terminalId, deadlineAt }) } catch { admission = null }
|
|
204
|
+
if (!admission?.ok) { runs.push({ scheduleId: schedule.id, code: admission?.code || 'spawn_not_admitted' }); continue }
|
|
205
|
+
let leaseTransferred = false
|
|
206
|
+
try {
|
|
207
|
+
const claimed = await rpc({ fetchImpl, supabaseUrl, headers, name: 'claim_due_code_schedule', body: {
|
|
208
|
+
p_schedule_id: schedule.id, p_expected_revision: schedule.revision, p_bridge_id: bridgeId,
|
|
209
|
+
p_bridge_fencing: bridgeFencing, p_occurrence_key: occurrenceKey, p_lease_seconds: leaseSeconds,
|
|
210
|
+
} })
|
|
211
|
+
if (!claimed?.ok || !claimed?.run) { runs.push({ scheduleId: schedule.id, code: claimed?.code || 'claim_failed' }); continue }
|
|
212
|
+
const admitted = preflightScheduledRun({ schedule, occurrence: claimed.run, ownerId, bridgeId, bridgeFencing, providerAvailable, capsOk: true, now })
|
|
213
|
+
if (!admitted.ok) { runs.push({ scheduleId: schedule.id, runId: claimed.run.id, code: admitted.code }); continue }
|
|
214
|
+
const opened = await rpc({ fetchImpl, supabaseUrl, headers, name: 'open_code_scheduled_run', body: {
|
|
215
|
+
p_run_id: claimed.run.id, p_bridge_id: bridgeId, p_bridge_fencing: bridgeFencing, p_terminal_id: terminalId, p_next_run_at: next.toISOString(),
|
|
216
|
+
} })
|
|
217
|
+
if (!opened?.ok) { runs.push({ scheduleId: schedule.id, runId: claimed.run.id, code: opened?.code || 'open_not_persisted' }); continue }
|
|
218
|
+
const openedRun = opened.run || claimed.run
|
|
219
|
+
let started = null
|
|
220
|
+
try { started = await openTerminal({ schedule, run: openedRun, terminalId, admission: admission.lease }) }
|
|
221
|
+
catch { started = null }
|
|
222
|
+
if (started) {
|
|
223
|
+
leaseTransferred = true
|
|
224
|
+
runs.push({ scheduleId: schedule.id, runId: claimed.run.id, code: 'opened' })
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
const failed = await rpc({ fetchImpl, supabaseUrl, headers, name: 'finish_code_scheduled_run', body: {
|
|
228
|
+
p_run_id: claimed.run.id, p_bridge_id: bridgeId, p_bridge_fencing: bridgeFencing,
|
|
229
|
+
p_outcome: 'failed', p_outcome_code: 'terminal_open_failed', p_evidence_refs: [], p_decision_item_id: null,
|
|
230
|
+
} })
|
|
231
|
+
runs.push({ scheduleId: schedule.id, runId: claimed.run.id, code: failed?.ok ? 'open_failed' : (failed?.code || 'open_failed_unrecorded') })
|
|
232
|
+
} finally {
|
|
233
|
+
if (!leaseTransferred) {
|
|
234
|
+
try { await releaseRun(admission.lease) } catch { /* expiry remains the fail-closed release */ }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return Object.freeze({ ok: true, code: 'polled', runs: Object.freeze(runs) })
|
|
239
|
+
} catch { return Object.freeze({ ok: false, code: 'runner_read_failed', runs: [] }) }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function finishScheduledRun ({ fetchImpl = globalThis.fetch, supabaseUrl, anonKey, token, roomCode, runId, bridgeId, bridgeFencing, result, evidenceRefs, canceled = false } = {}) {
|
|
243
|
+
if (!supabaseUrl || !anonKey || !token || !roomCode || !runId || !bridgeId || !Number.isSafeInteger(bridgeFencing) || bridgeFencing < 1) return Object.freeze({ ok: false, code: 'outcome_unavailable' })
|
|
244
|
+
const headers = { apikey: anonKey, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
|
|
245
|
+
try {
|
|
246
|
+
const pendingControls = await rpc({ fetchImpl, supabaseUrl, headers, name: 'list_code_pair_controls', body: { p_session_code: String(roomCode).toUpperCase(), p_status: 'pending', p_limit: 100 } })
|
|
247
|
+
const outcome = classifyRunOutcome({ result, evidenceRefs, pendingControls, runId, canceled })
|
|
248
|
+
const written = await rpc({ fetchImpl, supabaseUrl, headers, name: 'finish_code_scheduled_run', body: {
|
|
249
|
+
p_run_id: runId, p_outcome: outcome.outcome, p_outcome_code: outcome.code,
|
|
250
|
+
p_bridge_id: bridgeId, p_bridge_fencing: bridgeFencing,
|
|
251
|
+
p_evidence_refs: outcome.evidence || [], p_decision_item_id: outcome.controlItemId || null,
|
|
252
|
+
} })
|
|
253
|
+
return Object.freeze({ ok: Boolean(written?.ok), code: written?.code || 'outcome_write_failed', outcome })
|
|
254
|
+
} catch { return Object.freeze({ ok: false, code: 'outcome_write_failed' }) }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function finishScheduledRunWithRetry (args = {}, { attempts = 3, delays = [100, 400], sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {}) {
|
|
258
|
+
const count = Math.max(1, Math.min(5, Number.isInteger(attempts) ? attempts : 3))
|
|
259
|
+
let last = Object.freeze({ ok: false, code: 'outcome_write_failed' })
|
|
260
|
+
for (let attempt = 0; attempt < count; attempt++) {
|
|
261
|
+
last = await finishScheduledRun(args)
|
|
262
|
+
if (last.ok) return last
|
|
263
|
+
if (attempt + 1 < count) await sleep(Math.max(0, Math.min(5000, Number(delays[attempt] ?? delays.at(-1) ?? 0) || 0)))
|
|
264
|
+
}
|
|
265
|
+
return last
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export { OUTCOMES }
|