thinkpool-pair 0.7.364 → 0.7.365
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/LICENSE +18 -0
- package/README.md +20 -0
- package/abort-turn-barrier.mjs +1 -23
- package/account.mjs +1 -1437
- package/acp-client.mjs +1 -140
- package/agent-detect.mjs +1 -28
- package/agent-notify.mjs +1 -142
- package/agent-visibility.mjs +1 -67
- package/auth-store.mjs +1 -120
- package/bridge.mjs +1 -6266
- package/byok-detect.mjs +1 -126
- package/claude-command-catalog.mjs +1 -91
- package/claude-session.mjs +1 -1519
- package/code-event-contract.mjs +1 -118
- package/codex-app-server.mjs +1 -340
- package/codex-commands.mjs +1 -83
- package/codex-event-mapper.mjs +1 -232
- package/codex-images.mjs +1 -69
- package/codex-mcp-http.mjs +1 -131
- package/codex-session.mjs +1 -1295
- package/command-catalog.mjs +1 -118
- package/command-guidance.mjs +1 -8
- package/context-contract.mjs +1 -95
- package/context-windows.mjs +1 -107
- package/cross-terminal.mjs +1 -789
- package/cumulative-event-relay.mjs +1 -53
- package/design-edit.mjs +1 -424
- package/design-source-contract.mjs +1 -4
- package/direct-pair-room.mjs +1 -57
- package/dispatch-lease.mjs +1 -37
- package/dispatch-permission-cleanup.mjs +1 -86
- package/edit-diff.mjs +1 -136
- package/error-recovery.mjs +1 -50
- package/event-bounds.mjs +1 -121
- package/event-delivery-queue.mjs +1 -60
- package/event-id.mjs +1 -549
- package/evidence-citations.mjs +1 -50
- package/evidence-compact.mjs +1 -11
- package/flow-assembly.mjs +1 -196
- package/flow-budget.mjs +1 -84
- package/flow-conductor.mjs +1 -259
- package/flow-context-store.mjs +1 -387
- package/flow-host-revert.mjs +1 -42
- package/flow-models.mjs +1 -139
- package/flow-preview.mjs +1 -148
- package/flow-receipt.mjs +1 -122
- package/flow-redispatch.mjs +1 -72
- package/flow-review-gate.mjs +1 -402
- package/flow-review-reflect.mjs +1 -115
- package/flow-review.mjs +1 -152
- package/flow-scope-evidence.mjs +1 -117
- package/flow-skill-registry.mjs +1 -140
- package/flow-task-graph.mjs +1 -562
- package/flow-worktree.mjs +1 -71
- package/git-diff-report.mjs +1 -121
- package/hermes-delegation-guard.mjs +1 -14
- package/hermes-event-mapper.mjs +1 -194
- package/hermes-isolation.mjs +1 -61
- package/hermes-model-cache.mjs +1 -54
- package/hermes-policy.mjs +1 -92
- package/hermes-probe.mjs +1 -57
- package/hermes-session.mjs +1 -683
- package/hermes-setup.mjs +1 -167
- package/host-memory.mjs +1 -116
- package/interrupted-resume.mjs +1 -95
- package/keep-awake.mjs +1 -148
- package/key-shape.mjs +1 -49
- package/lane-continuation.mjs +1 -83
- package/lane-lifecycle.mjs +1 -189
- package/lane-worktree.mjs +1 -77
- package/launcher.mjs +1 -354
- package/mcp-flight-recorder.mjs +1 -79
- package/mockup-delivery.mjs +1 -57
- package/model-prices.mjs +1 -113
- package/package.json +13 -4
- package/pair-bus.mjs +1 -98
- package/pair-control-authority.mjs +1 -89
- package/past-work-search.mjs +1 -105
- package/plan-meters.mjs +1 -144
- package/presence.mjs +1 -191
- package/privacy-report.mjs +1 -108
- package/provider-resilience.mjs +1 -356
- package/provider.mjs +1 -133
- package/providers.mjs +1 -491
- package/publish-guard.mjs +2 -0
- package/publish-manifest.json +129 -0
- package/question-response.mjs +1 -58
- package/reap-terminal.mjs +1 -68
- package/recap.mjs +1 -297
- package/replay-transport.mjs +1 -64
- package/repo-search.mjs +1 -190
- package/review-check.mjs +1 -182
- package/runtime-contract.mjs +1 -93
- package/runtime-registry.mjs +1 -64
- package/runtime-session.mjs +1 -20
- package/scheduled-run-admission.mjs +1 -364
- package/scheduled-runs.mjs +1 -268
- package/sdk-admission.mjs +1 -9
- package/sdk-smoke.mjs +1 -61
- package/serve-consent.mjs +1 -118
- package/serve-dir.mjs +1 -40
- package/service.mjs +1 -877
- package/session-store.mjs +1 -484
- package/side-lane.mjs +1 -63
- package/supabase-key.mjs +1 -176
- package/supervisor-ready.mjs +1 -57
- package/switch-provider.mjs +1 -84
- package/terminal-name.mjs +1 -359
- package/terminal-row-reconcile.mjs +1 -70
- package/thinkpool-prompt-contracts.mjs +1 -85
- package/thinkpool-room-prompt.mjs +1 -187
- package/transcript-sanitize.mjs +1 -332
- package/turn-stall.mjs +1 -61
- package/update-gate.mjs +1 -53
- package/viewport.mjs +1 -810
- package/worker-completion.mjs +1 -57
package/scheduled-runs.mjs
CHANGED
|
@@ -1,268 +1 @@
|
|
|
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 }
|
|
1
|
+
import{randomUUID as e}from"node:crypto";import{SPAWN as t,spawnDecision as n}from"./cross-terminal.mjs";const i=new Set(["once","daily","weekly"]),r=new Set(["claude","codex","hermes"]),d=new Set(["delivered","needs_decision","failed","canceled"]),o=new Set(["artifact","preview","commit","test","flow_task","control_item"]),c=/(?: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,a=/(?:^|\s)(?:~\/|\/Users\/|\/home\/|\/private\/|\/tmp\/|[A-Za-z]:\\|\.\.[\\/])/i,u=/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/,s=/^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/,l=new Map;export const scheduledRunsEnabled=(e=process.env.TP_SCHEDULED_RUNS_ENABLED)=>"1"===e;export const scheduledRunsCapability=(e=process.env.TP_SCHEDULED_RUNS_ENABLED)=>Object.freeze({enabled:scheduledRunsEnabled(e)});const p=(e,t)=>"string"==typeof e&&e.trim().length>0&&e.length<=t&&!c.test(e)&&!a.test(e),_=e=>e instanceof Date?e:new Date(e),m=e=>!Number.isNaN(_(e).getTime()),f=(e,t)=>{let n=l.get(e);n||(n=new Intl.DateTimeFormat("en-CA",{timeZone:e,weekday:"short",hour:"2-digit",minute:"2-digit",hourCycle:"h23"}),l.set(e,n));const i=Object.fromEntries(n.formatToParts(t).filter(e=>"literal"!==e.type).map(e=>[e.type,e.value]));return{weekday:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"].indexOf(i.weekday),time:`${i.hour}:${i.minute}`}};export function scheduleValidationError(e={}){if(!e||"object"!=typeof e||!i.has(e.kind))return"invalid_kind";if(!p(e.title,120)||!p(e.prompt,12e3))return"unsafe_or_invalid_text";if(!r.has(e.runtime))return"invalid_runtime";if(!(null==e.provider||p(e.provider,80)&&u.test(e.provider)))return"invalid_provider";if(!(null==e.model||p(e.model,160)&&u.test(e.model)))return"invalid_model";if(!Number.isInteger(e.maxRuntimeMinutes)||e.maxRuntimeMinutes<1||e.maxRuntimeMinutes>30)return"invalid_max_runtime";try{new Intl.DateTimeFormat("en",{timeZone:e.timezone})}catch{return"invalid_timezone"}return"once"!==e.kind||m(e.at)?"once"!==e.kind&&!/^\d{2}:\d{2}$/.test(e.time)||"once"!==e.kind&&(Number(e.time.slice(0,2))>23||Number(e.time.slice(3))>59)?"invalid_local_time":"weekly"===e.kind&&(!Number.isInteger(e.weekday)||e.weekday<0||e.weekday>6)?"invalid_weekday":null:"invalid_once_time"}export function normalizeSchedule(e={},t=new Date){const n=scheduleValidationError(e);if(n)return Object.freeze({ok:!1,code:n});const i={kind:e.kind,title:e.title.trim(),prompt:e.prompt.trim(),runtime:e.runtime,provider:e.provider||null,model:e.model||null,timezone:e.timezone,maxRuntimeMinutes:e.maxRuntimeMinutes,..."once"===e.kind?{at:_(e.at).toISOString()}:{time:e.time,..."weekly"===e.kind?{weekday:e.weekday}:{}}},r=nextScheduledAt(i,t);return r?Object.freeze({ok:!0,value:Object.freeze(i),nextRunAt:r.toISOString()}):Object.freeze({ok:!1,code:"schedule_exhausted"})}export function nextScheduledAt(e,t=new Date){if("once"===e?.kind){const n=_(e.at);return m(n)&&n.getTime()>_(t).getTime()?n:null}const n=6e4*Math.floor(_(t).getTime()/6e4)+6e4;for(let t=n,i=n+3456e7;t<i;t+=6e4){const n=f(e.timezone,new Date(t));if(n.time===e.time&&("daily"===e.kind||n.weekday===e.weekday))return new Date(t)}return null}export function classifyRunOutcome({result:e=null,evidenceRefs:t=[],pendingControls:n=[],runId:i=null,canceled:r=!1}={}){if("runtime_limit"===e?.subtype)return Object.freeze({outcome:"failed",code:"runtime_limit"});if(r||["aborted","canceled","cancelled","interrupted"].includes(e?.subtype))return Object.freeze({outcome:"canceled",code:"canceled"});const d=(Array.isArray(t)?t:[]).filter(e=>e&&o.has(e.type)&&"string"==typeof e.id&&u.test(e.id)).slice(0,16);if(d.length)return Object.freeze({outcome:"delivered",code:"evidence_delivered",evidence:d});const c=(Array.isArray(n)?n:[]).find(e=>"pending"===e?.status&&e?.id&&e?.request_context?.run_id&&(!i||e.request_context.run_id===i)&&Number.isSafeInteger(Number(e.source_event_from_seq)));return c?Object.freeze({outcome:"needs_decision",code:"pending_pair_control",controlItemId:c.id}):"success"===e?.subtype?Object.freeze({outcome:"failed",code:"missing_deliverable"}):Object.freeze({outcome:"failed",code:e?.subtype||"runtime_failed"})}export function sanitizeScheduledOutcomeIntent({runId:e,result:t=null,evidenceRefs:n=[],canceled:i=!1}={}){if("string"!=typeof e||!u.test(e))return null;const r="string"==typeof t?.subtype&&s.test(t.subtype)?t.subtype:"runtime_failed";return Object.freeze({runId:e,result:Object.freeze({subtype:r}),evidenceRefs:Object.freeze((d=n,(Array.isArray(d)?d:[]).filter(e=>e&&o.has(e.type)&&"string"==typeof e.id&&u.test(e.id)).slice(0,16).map(e=>Object.freeze({type:e.type,id:e.id})))),canceled:!0===i});var d}export function scheduledRunDeadlineRequired({runId:e,deadlineAt:t,outcomeRecorded:n=!1,outcomePending:i=null}={}){return Boolean(e&&Number.isFinite(t)&&!n&&!i)}export function scheduledOutcomeReconciliationComplete(e=[]){return Array.isArray(e)&&0===e.length}export function scheduledRunSpawnAdmission({spawnTimes:e=[],now:i=Date.now(),totalLive:r=0,disabled:d=!1}={},o=t){const c={...t,...o||{}},a=(Array.isArray(e)?e:[]).filter(e=>Number.isFinite(e)&&e>i-c.windowMs).slice(-(c.perWindowCap||1)),u=n({hop:0,spawnTimes:a,now:i,spawnedLive:0,totalLive:r,plan:"free",disabled:d},c);return Object.freeze({...u,spawnTimes:Object.freeze(u.ok?[...a,i].slice(-c.perWindowCap):a)})}export function preflightScheduledRun({schedule:e,occurrence:t,ownerId:n,bridgeId:i,bridgeFencing:r,providerAvailable:d,capsOk:o=!0,now:c=new Date}={}){return e?.enabled?t?.id&&t.schedule_revision===e.revision?!n||!i||!Number.isSafeInteger(r)||r<1||t.owner_id!==n||t.bridge_id!==i||t.bridge_fencing!==r?Object.freeze({ok:!1,code:"owner_bridge_mismatch"}):o?"function"==typeof d&&d(e)?!t.lease_expires_at||_(t.lease_expires_at).getTime()<=_(c).getTime()?Object.freeze({ok:!1,code:"lease_expired"}):Object.freeze({ok:!0,code:"admitted"}):Object.freeze({ok:!1,code:"provider_unavailable"}):Object.freeze({ok:!1,code:"runtime_cap_exceeded"}):Object.freeze({ok:!1,code:"stale_schedule_revision"}):Object.freeze({ok:!1,code:"schedule_disabled"})}const h=async({fetchImpl:e,supabaseUrl:t,headers:n,name:i,body:r})=>{const d=await e(`${String(t).replace(/\/$/,"")}/rest/v1/rpc/${i}`,{method:"POST",headers:n,body:JSON.stringify(r)});if(!d?.ok)throw new Error(`${i}_failed`);return d.json()};export async function pollDueScheduledRuns({enabled:t=!1,fetchImpl:n=globalThis.fetch,supabaseUrl:i,anonKey:r,token:d,roomCode:o,bridgeId:c,bridgeFencing:a,ownerId:s,providerAvailable:l,openTerminal:p,admitRun:m,releaseRun:f,makeTerminalId:b=e,capsOk:y=()=>!0,now:g=new Date}={}){if(!t)return Object.freeze({ok:!1,code:"feature_disabled",runs:[]});if(!("function"==typeof n&&i&&r&&d&&o&&c&&Number.isSafeInteger(a))||a<1||!s||"function"!=typeof p||"function"!=typeof m||"function"!=typeof f||"function"!=typeof b)return Object.freeze({ok:!1,code:"runner_unavailable",runs:[]});const w={apikey:r,Authorization:`Bearer ${d}`,"Content-Type":"application/json"},k=String(o).trim().toUpperCase();try{const e=await h({fetchImpl:n,supabaseUrl:i,headers:w,name:"expire_code_scheduled_runs",body:{p_room_code:k,p_bridge_id:c,p_bridge_fencing:a,p_limit:24}});if(!e?.ok)throw new Error("expire_code_scheduled_runs_failed");const t=await h({fetchImpl:n,supabaseUrl:i,headers:w,name:"list_due_code_schedules",body:{p_room_code:k,p_limit:12}}),r=[];for(const e of Array.isArray(t)?t:[]){if(String(e?.session_code||"").trim().toUpperCase()!==k){r.push({scheduleId:e?.id,code:"room_scope_mismatch"});continue}if(!y(e)){r.push({scheduleId:e.id,code:"runtime_cap_exceeded"});continue}if("function"!=typeof l||!l(e)){r.push({scheduleId:e.id,code:"provider_unavailable"});continue}const t=b();if("string"!=typeof t||!u.test(t)){r.push({scheduleId:e.id,code:"terminal_id_unavailable"});continue}const d={...e.schedule_spec||{},kind:e.schedule_kind||e.kind,timezone:e.timezone},o="once"===d.kind?new Date("9999-12-31T23:59:00.000Z"):nextScheduledAt(d,new Date(e.next_run_at));if(!o){r.push({scheduleId:e.id,code:"next_run_invalid"});continue}const z=`${e.id}:${e.next_run_at}`,v=Math.min(1860,Math.max(60,60*e.max_runtime_minutes+60)),I=_(g).getTime()+1e3*v;let x=null;try{x=await m({schedule:e,runId:z,terminalId:t,deadlineAt:I})}catch{x=null}if(!x?.ok){r.push({scheduleId:e.id,code:x?.code||"spawn_not_admitted"});continue}let O=!1;try{const d=await h({fetchImpl:n,supabaseUrl:i,headers:w,name:"claim_due_code_schedule",body:{p_schedule_id:e.id,p_expected_revision:e.revision,p_bridge_id:c,p_bridge_fencing:a,p_occurrence_key:z,p_lease_seconds:v}});if(!d?.ok||!d?.run){r.push({scheduleId:e.id,code:d?.code||"claim_failed"});continue}const u=preflightScheduledRun({schedule:e,occurrence:d.run,ownerId:s,bridgeId:c,bridgeFencing:a,providerAvailable:l,capsOk:!0,now:g});if(!u.ok){r.push({scheduleId:e.id,runId:d.run.id,code:u.code});continue}const _=await h({fetchImpl:n,supabaseUrl:i,headers:w,name:"open_code_scheduled_run",body:{p_run_id:d.run.id,p_bridge_id:c,p_bridge_fencing:a,p_terminal_id:t,p_next_run_at:o.toISOString()}});if(!_?.ok){r.push({scheduleId:e.id,runId:d.run.id,code:_?.code||"open_not_persisted"});continue}const m=_.run||d.run;let f=null;try{f=await p({schedule:e,run:m,terminalId:t,admission:x.lease})}catch{f=null}if(f){O=!0,r.push({scheduleId:e.id,runId:d.run.id,code:"opened"});continue}const b=await h({fetchImpl:n,supabaseUrl:i,headers:w,name:"finish_code_scheduled_run",body:{p_run_id:d.run.id,p_bridge_id:c,p_bridge_fencing:a,p_outcome:"failed",p_outcome_code:"terminal_open_failed",p_evidence_refs:[],p_decision_item_id:null}});r.push({scheduleId:e.id,runId:d.run.id,code:b?.ok?"open_failed":b?.code||"open_failed_unrecorded"})}finally{if(!O)try{await f(x.lease)}catch{}}}return Object.freeze({ok:!0,code:"polled",runs:Object.freeze(r)})}catch{return Object.freeze({ok:!1,code:"runner_read_failed",runs:[]})}}export async function finishScheduledRun({fetchImpl:e=globalThis.fetch,supabaseUrl:t,anonKey:n,token:i,roomCode:r,runId:d,bridgeId:o,bridgeFencing:c,result:a,evidenceRefs:u,canceled:s=!1}={}){if(!(t&&n&&i&&r&&d&&o&&Number.isSafeInteger(c))||c<1)return Object.freeze({ok:!1,code:"outcome_unavailable"});const l={apikey:n,Authorization:`Bearer ${i}`,"Content-Type":"application/json"};try{const n=classifyRunOutcome({result:a,evidenceRefs:u,pendingControls:await h({fetchImpl:e,supabaseUrl:t,headers:l,name:"list_code_pair_controls",body:{p_session_code:String(r).toUpperCase(),p_status:"pending",p_limit:100}}),runId:d,canceled:s}),i=await h({fetchImpl:e,supabaseUrl:t,headers:l,name:"finish_code_scheduled_run",body:{p_run_id:d,p_outcome:n.outcome,p_outcome_code:n.code,p_bridge_id:o,p_bridge_fencing:c,p_evidence_refs:n.evidence||[],p_decision_item_id:n.controlItemId||null}});return Object.freeze({ok:Boolean(i?.ok),code:i?.code||"outcome_write_failed",outcome:n})}catch{return Object.freeze({ok:!1,code:"outcome_write_failed"})}}export async function finishScheduledRunWithRetry(e={},{attempts:t=3,delays:n=[100,400],sleep:i=e=>new Promise(t=>setTimeout(t,e))}={}){const r=Math.max(1,Math.min(5,Number.isInteger(t)?t:3));let d=Object.freeze({ok:!1,code:"outcome_write_failed"});for(let t=0;t<r;t++){if(d=await finishScheduledRun(e),d.ok)return d;t+1<r&&await i(Math.max(0,Math.min(5e3,Number(n[t]??n.at(-1)??0)||0)))}return d}export{d as OUTCOMES};
|
package/sdk-admission.mjs
CHANGED
|
@@ -1,9 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// remain available so an operator can inspect or repair a bad SDK installation.
|
|
3
|
-
export function requiresSdkAdmission(argv) {
|
|
4
|
-
return Boolean(argv?.[0]) && argv[0] !== 'verify-sdk'
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export function sdkSmokePassed(output) {
|
|
8
|
-
return /^SMOKE:PASS:[^:\n]+(?:\n|$)/.test(String(output || ''))
|
|
9
|
-
}
|
|
1
|
+
export function requiresSdkAdmission(e){return Boolean(e?.[0])&&"verify-sdk"!==e[0]}export function sdkSmokePassed(e){return/^SMOKE:PASS:[^:\n]+(?:\n|$)/.test(String(e||""))}
|
package/sdk-smoke.mjs
CHANGED
|
@@ -1,61 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// The bridge tracks @anthropic-ai/claude-agent-sdk on a caret (^0.3.x), so a
|
|
4
|
-
// restart can auto-pull a NEW SDK. Our own history (feedback_bridge_sdk_pin_no_bundle)
|
|
5
|
-
// is that the SDK's interrupt/turn API has broken between 0.3.x minors. This verifies
|
|
6
|
-
// the API SURFACE the bridge depends on — BEFORE the bridge commits to serving with a
|
|
7
|
-
// freshly-pulled SDK — so a bad one is caught + surfaced instead of silently breaking
|
|
8
|
-
// every turn.
|
|
9
|
-
//
|
|
10
|
-
// Deliberately CHEAP + OFFLINE: no Claude key, no network, no token spend. It only
|
|
11
|
-
// checks that the SDK imports, query() constructs, and the control methods the bridge
|
|
12
|
-
// calls (interrupt / setModel / setPermissionMode / supportedModels) exist as
|
|
13
|
-
// functions. It does NOT run a real turn (that would cost tokens + need auth). A
|
|
14
|
-
// surface break — a removed/renamed method — is the failure mode this guards; a subtle
|
|
15
|
-
// behavioural break still needs a live turn (out of scope for a boot gate).
|
|
16
|
-
//
|
|
17
|
-
// Runs as a SHORT-LIVED SUBPROCESS with a hard timeout, so a hung SDK import/construct
|
|
18
|
-
// = FAIL, never a hung bridge. One line to stdout: `SMOKE:PASS:<version>` /
|
|
19
|
-
// `SMOKE:FAIL:<version>:<reason>`. Exit 0 = pass, 1 = incompatible, 2 = timeout.
|
|
20
|
-
|
|
21
|
-
import { createRequire } from 'node:module'
|
|
22
|
-
import { readFileSync } from 'node:fs'
|
|
23
|
-
import { dirname, join } from 'node:path'
|
|
24
|
-
|
|
25
|
-
const HARD_TIMEOUT_MS = Math.max(4000, parseInt(process.env.TP_SDK_SMOKE_TIMEOUT, 10) || 12000)
|
|
26
|
-
const guard = setTimeout(() => { emit('FAIL', ver(), 'timeout importing/constructing the SDK'); process.exit(2) }, HARD_TIMEOUT_MS)
|
|
27
|
-
|
|
28
|
-
function emit(status, version, reason) {
|
|
29
|
-
try { process.stdout.write(`SMOKE:${status}:${version || 'unknown'}${reason ? ':' + reason : ''}\n`) } catch { /* noop */ }
|
|
30
|
-
}
|
|
31
|
-
// Resolve the SDK's real version from wherever node just resolved the package (the
|
|
32
|
-
// caret pick), by walking up from its entry to the package.json with the right name.
|
|
33
|
-
function ver() {
|
|
34
|
-
try {
|
|
35
|
-
const require = createRequire(import.meta.url)
|
|
36
|
-
let d = dirname(require.resolve('@anthropic-ai/claude-agent-sdk'))
|
|
37
|
-
for (let i = 0; i < 8; i++) {
|
|
38
|
-
try { const p = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')); if (p.name === '@anthropic-ai/claude-agent-sdk') return p.version } catch { /* keep walking */ }
|
|
39
|
-
const up = dirname(d); if (up === d) break; d = up
|
|
40
|
-
}
|
|
41
|
-
} catch { /* unresolved */ }
|
|
42
|
-
return 'unknown'
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const version = ver()
|
|
46
|
-
try {
|
|
47
|
-
const sdk = await import('@anthropic-ai/claude-agent-sdk')
|
|
48
|
-
if (typeof sdk.query !== 'function') { emit('FAIL', version, 'query is not a function'); clearTimeout(guard); process.exit(1) }
|
|
49
|
-
// eslint-disable-next-line require-yield -- deliberately empty: an async generator that ends immediately (the SDK's "no input" stream), never yields.
|
|
50
|
-
async function* noInput() { return } // ends immediately — never blocks the construct
|
|
51
|
-
const q = sdk.query({ prompt: noInput(), options: { permissionMode: 'default' } })
|
|
52
|
-
const need = ['interrupt', 'setModel', 'setPermissionMode', 'supportedModels']
|
|
53
|
-
const missing = need.filter((m) => typeof q?.[m] !== 'function')
|
|
54
|
-
try { q?.interrupt?.() } catch { /* don't leave it running */ }
|
|
55
|
-
if (missing.length) { emit('FAIL', version, `missing methods: ${missing.join('/')}`); clearTimeout(guard); process.exit(1) }
|
|
56
|
-
emit('PASS', version)
|
|
57
|
-
clearTimeout(guard); process.exit(0)
|
|
58
|
-
} catch (e) {
|
|
59
|
-
emit('FAIL', version, `import/construct threw: ${(e?.message || e).toString().slice(0, 120)}`)
|
|
60
|
-
clearTimeout(guard); process.exit(1)
|
|
61
|
-
}
|
|
1
|
+
import{createRequire as t}from"node:module";import{readFileSync as e}from"node:fs";import{dirname as o,join as n}from"node:path";const r=Math.max(4e3,parseInt(process.env.TP_SDK_SMOKE_TIMEOUT,10)||12e3),i=setTimeout(()=>{s("FAIL",c(),"timeout importing/constructing the SDK"),process.exit(2)},r);function s(t,e,o){try{process.stdout.write(`SMOKE:${t}:${e||"unknown"}${o?":"+o:""}\n`)}catch{}}function c(){try{const r=t(import.meta.url);let i=o(r.resolve("@anthropic-ai/claude-agent-sdk"));for(let t=0;t<8;t++){try{const t=JSON.parse(e(n(i,"package.json"),"utf8"));if("@anthropic-ai/claude-agent-sdk"===t.name)return t.version}catch{}const t=o(i);if(t===i)break;i=t}}catch{}return"unknown"}const a=c();try{const u=await import("@anthropic-ai/claude-agent-sdk");async function*p(){}"function"!=typeof u.query&&(s("FAIL",a,"query is not a function"),clearTimeout(i),process.exit(1));const m=u.query({prompt:p(),options:{permissionMode:"default"}}),d=["interrupt","setModel","setPermissionMode","supportedModels"].filter(t=>"function"!=typeof m?.[t]);try{m?.interrupt?.()}catch{}d.length&&(s("FAIL",a,`missing methods: ${d.join("/")}`),clearTimeout(i),process.exit(1)),s("PASS",a),clearTimeout(i),process.exit(0)}catch(f){s("FAIL",a,`import/construct threw: ${(f?.message||f).toString().slice(0,120)}`),clearTimeout(i),process.exit(1)}
|
package/serve-consent.mjs
CHANGED
|
@@ -1,118 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// Spec: docs/specs/2026-07-06-remove-cross-person-serve.md (Contract C-CODE-3, evolved).
|
|
4
|
-
// A room is served by its OWNER's bridge ONLY. No cross-person serving, no grantee
|
|
5
|
-
// path (removed 2026-07-06 — the cross-person grant was a recipe for the wrong-session
|
|
6
|
-
// fuckery). The owner's own bridge on a second machine is handled by the per-account
|
|
7
|
-
// claim_bridge, not here. Pairing stays read-only (peer peek across the pair).
|
|
8
|
-
//
|
|
9
|
-
// This module holds ONLY pure, order-independent decisions (unit-tested in
|
|
10
|
-
// bridge/test-serve-consent.mjs) plus a single network helper. The wiring — where the
|
|
11
|
-
// gate runs — lives in bridge.mjs. Keeping the logic pure is the
|
|
12
|
-
// feedback_code_seed_order_independent discipline: no decision depends on live
|
|
13
|
-
// channel/session state, so every branch is testable off-wire.
|
|
14
|
-
//
|
|
15
|
-
// LIMITATION (documented): enforcement is in-bridge. A bridge OLDER than this version
|
|
16
|
-
// may still honor a grant — there is no server-side wire-level lock. The set_serve_grant
|
|
17
|
-
// RPC + serve_grant_uid column were dropped server-side in the same 2026-07-06 migration,
|
|
18
|
-
// so even an old bridge can no longer READ a grant (the column is gone) and will refuse
|
|
19
|
-
// as a non-owner. That closes the bypass.
|
|
20
|
-
|
|
21
|
-
import { pairCli } from './command-guidance.mjs'
|
|
22
|
-
|
|
23
|
-
// serveDecision — may THIS bridge serve THIS room?
|
|
24
|
-
// ownerId : code_sessions.owner_id (uuid | null)
|
|
25
|
-
// myUid : this bridge's authed user id (uuid | null) — null when the bridge is anon
|
|
26
|
-
// Returns { serve, reason }:
|
|
27
|
-
// reason 'owner' — myUid owns the room (the only serve path)
|
|
28
|
-
// reason 'no-identity' — bridge has no authed uid → refuse (no anon serve path)
|
|
29
|
-
// reason 'not-owner' — authed, but not the owner → refuse
|
|
30
|
-
// reason 'unknown-room'— no owner_id resolved (row missing / RLS-hidden) → refuse
|
|
31
|
-
export function serveDecision ({ ownerId, myUid }) {
|
|
32
|
-
if (!myUid) return { serve: false, reason: 'no-identity' }
|
|
33
|
-
if (!ownerId) return { serve: false, reason: 'unknown-room' }
|
|
34
|
-
if (myUid === ownerId) return { serve: true, reason: 'owner' }
|
|
35
|
-
return { serve: false, reason: 'not-owner' }
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// supervisorServes — the account supervisor's per-row discovery decision: may this
|
|
39
|
-
// login auto-serve THIS discovered room? True iff the login OWNS the room. This is the
|
|
40
|
-
// defense-in-depth per-row check behind the supervisor's owner-only `.eq(owner_id, me)`
|
|
41
|
-
// query — a broad RLS row (owned OR merely participant) must still resolve to OWNED
|
|
42
|
-
// before a child is spawned. Contract C-CODE-2 (owner-only).
|
|
43
|
-
export function supervisorServes ({ ownerId, myUid, closedAt = null }) {
|
|
44
|
-
if (!myUid) return false
|
|
45
|
-
return ownerId === myUid && !closedAt
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// supervisorRoomsToStop — reconcile the long-lived child map after a SUCCESSFUL
|
|
49
|
-
// discovery read. Closing, deleting, or transferring a room removes it from the
|
|
50
|
-
// active-owned result set; any existing child for that room must be retired even
|
|
51
|
-
// when the best-effort `session-deleted` realtime broadcast was missed.
|
|
52
|
-
//
|
|
53
|
-
// This stays pure so a transient discovery failure can be handled by the caller:
|
|
54
|
-
// account.mjs invokes it only after Supabase returned without an error. On a failed
|
|
55
|
-
// fetch the existing children stay alive and the next tick retries.
|
|
56
|
-
export function supervisorRoomsToStop ({ rooms, childRooms, myUid }) {
|
|
57
|
-
const activeOwned = new Set((rooms || [])
|
|
58
|
-
.filter((room) => supervisorServes({
|
|
59
|
-
ownerId: room?.owner_id,
|
|
60
|
-
myUid,
|
|
61
|
-
closedAt: room?.closed_at,
|
|
62
|
-
}))
|
|
63
|
-
.map((room) => room.code)
|
|
64
|
-
.filter(Boolean))
|
|
65
|
-
return [...(childRooms || [])].filter((code) => !activeOwned.has(code))
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// fetchServeRow — read the room's owner with whatever identity the bridge holds
|
|
69
|
-
// (authed token → RLS returns the row if the user is a member; anon → empty). THROWS on
|
|
70
|
-
// a non-OK response or a transport error. The thrown error carries `.authHard` so the
|
|
71
|
-
// caller can tell the two apart (Contract C-CODE-3 invariant 2, hardened 2026-07-05):
|
|
72
|
-
// - 401/403 (authHard === true) → auth is DEFINITIVELY bad (dead/expired token). The
|
|
73
|
-
// caller REFUSES: a stale access token must never fail OPEN into serving, or the gate
|
|
74
|
-
// is bypassed in the common case (stored tokens expire ~1h). The caller refreshes the
|
|
75
|
-
// token BEFORE the gate, so a 401 here means the refresh also failed → genuinely unauthed.
|
|
76
|
-
// - 5xx / genuine fetch() reject (authHard === false) → availability failure. The caller
|
|
77
|
-
// fails OPEN (never strand a user on a flaky network / a Supabase blip).
|
|
78
|
-
// A clean HTTP response that simply yields no row is NOT a throw — it returns
|
|
79
|
-
// { ownerId: null } and the caller refuses (unknown room).
|
|
80
|
-
export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
|
|
81
|
-
const headers = { apikey: anonKey }
|
|
82
|
-
if (token) headers.Authorization = `Bearer ${token}`
|
|
83
|
-
const r = await fetch(
|
|
84
|
-
`${supabaseUrl}/rest/v1/code_sessions?select=owner_id,name&code=eq.${encodeURIComponent(room)}`,
|
|
85
|
-
{ headers },
|
|
86
|
-
)
|
|
87
|
-
if (!r.ok) {
|
|
88
|
-
const err = new Error(`code_sessions fetch ${r.status}`)
|
|
89
|
-
err.status = r.status
|
|
90
|
-
err.authHard = r.status === 401 || r.status === 403
|
|
91
|
-
throw err
|
|
92
|
-
}
|
|
93
|
-
const rows = await r.json()
|
|
94
|
-
const row = Array.isArray(rows) ? rows[0] : null
|
|
95
|
-
return {
|
|
96
|
-
ownerId: row?.owner_id ?? null,
|
|
97
|
-
name: row?.name ?? null,
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// gateFailAction — pure classifier for a THROWN fetchServeRow error at the boot gate.
|
|
102
|
-
// 'refuse' — auth is definitively bad (401/403 after a token refresh); do NOT fail open.
|
|
103
|
-
// 'fail-open' — an availability error (5xx, offline, transport reject); serve rather than
|
|
104
|
-
// strand a user on a flaky fetch. This is the ONLY fail-open path.
|
|
105
|
-
export function gateFailAction (err) {
|
|
106
|
-
return err?.authHard ? 'refuse' : 'fail-open'
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// refusalMessage — the human-facing stderr line when a bridge refuses to serve.
|
|
110
|
-
export function refusalMessage ({ room, name, reason }) {
|
|
111
|
-
const label = name ? `"${name}"` : '(unnamed)'
|
|
112
|
-
if (reason === 'no-identity') {
|
|
113
|
-
return `\n ◇ Refusing to serve room ${room} — this bridge is not signed in.\n` +
|
|
114
|
-
` A room is served only by its owner's bridge. Sign in first: ${pairCli('login')}\n`
|
|
115
|
-
}
|
|
116
|
-
return `\n ◇ Refusing to serve room ${room} ${label} — it is owned by another person; only their bridge serves it.\n` +
|
|
117
|
-
` Pairing gives you a read-only view across the pair, not hosting rights. There is no override.\n`
|
|
118
|
-
}
|
|
1
|
+
import{pairCli as e}from"./command-guidance.mjs";export function serveDecision({ownerId:e,myUid:o}){return o?e?o===e?{serve:!0,reason:"owner"}:{serve:!1,reason:"not-owner"}:{serve:!1,reason:"unknown-room"}:{serve:!1,reason:"no-identity"}}export function supervisorServes({ownerId:e,myUid:o,closedAt:n=null}){return!!o&&e===o&&!n}export function supervisorRoomsToStop({rooms:e,childRooms:o,myUid:n}){const r=new Set((e||[]).filter(e=>supervisorServes({ownerId:e?.owner_id,myUid:n,closedAt:e?.closed_at})).map(e=>e.code).filter(Boolean));return[...o||[]].filter(e=>!r.has(e))}export async function fetchServeRow({supabaseUrl:e,anonKey:o,token:n,room:r}){const s={apikey:o};n&&(s.Authorization=`Bearer ${n}`);const t=await fetch(`${e}/rest/v1/code_sessions?select=owner_id,name&code=eq.${encodeURIComponent(r)}`,{headers:s});if(!t.ok){const e=new Error(`code_sessions fetch ${t.status}`);throw e.status=t.status,e.authHard=401===t.status||403===t.status,e}const i=await t.json(),a=Array.isArray(i)?i[0]:null;return{ownerId:a?.owner_id??null,name:a?.name??null}}export function gateFailAction(e){return e?.authHard?"refuse":"fail-open"}export function refusalMessage({room:o,name:n,reason:r}){const s=n?`"${n}"`:"(unnamed)";return"no-identity"===r?`\n ◇ Refusing to serve room ${o} — this bridge is not signed in.\n A room is served only by its owner's bridge. Sign in first: ${e("login")}\n`:`\n ◇ Refusing to serve room ${o} ${s} — it is owned by another person; only their bridge serves it.\n Pairing gives you a read-only view across the pair, not hosting rights. There is no override.\n`}
|
package/serve-dir.mjs
CHANGED
|
@@ -1,40 +1 @@
|
|
|
1
|
-
|
|
2
|
-
room from, and REFUSE the home-directory trap.
|
|
3
|
-
|
|
4
|
-
Why this exists (FVIHV1DE, 2026-06-30): the supervisor auto-serves an unbound room
|
|
5
|
-
from its own launch dir (DEFAULT_DIR = process.cwd()). When the supervisor is
|
|
6
|
-
relaunched from $HOME instead of the repo, every unbound room is served from $HOME.
|
|
7
|
-
That silently breaks two things:
|
|
8
|
-
1. The agent's cwd becomes ~, so the Claude SDK (which keys a session's transcript
|
|
9
|
-
by project directory) can't find the session that was created under the real
|
|
10
|
-
repo → resume fails → the agent is BLIND to its own history (the UI still replays
|
|
11
|
-
the persisted ThinkPool log, so "I see the chat but the agent can't").
|
|
12
|
-
2. The room's repo label is basename(cwd) = your home folder name ("max"/"conrad")
|
|
13
|
-
instead of "thinkpool" / a branch.
|
|
14
|
-
Loud refusal beats silent context corruption. Precedence:
|
|
15
|
-
explicit bind > last-served memory > the supervisor's launch dir
|
|
16
|
-
and an UNBOUND, never-served room is refused when the launch dir IS $HOME.
|
|
17
|
-
Pure (no fs/process) so it unit-tests without spawning bridges. */
|
|
18
|
-
import path from 'node:path'
|
|
19
|
-
|
|
20
|
-
const samePath = (a, b) => {
|
|
21
|
-
try { return !!a && !!b && path.resolve(a) === path.resolve(b) } catch { return false }
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// Returns { dir, refuse, reason, fromDefault }.
|
|
25
|
-
// - dir: the directory to serve from (even when refusing, for the message)
|
|
26
|
-
// - refuse: true → do NOT serve; warn the user to bind instead
|
|
27
|
-
// - reason: 'home' | 'none' when refusing
|
|
28
|
-
// - fromDefault:true → resolved to the supervisor's launch dir with no bind/memory
|
|
29
|
-
// (the caller should remember it after a successful serve, so a later
|
|
30
|
-
// relaunch-from-home resumes the room from this same repo)
|
|
31
|
-
export function resolveServeDir({ bound, remembered, defaultDir, home } = {}) {
|
|
32
|
-
const pick = bound || remembered || defaultDir || null
|
|
33
|
-
if (!pick) return { dir: null, refuse: true, reason: 'none' }
|
|
34
|
-
// Only the AUTO path (no explicit bind, no remembered dir) is guarded — if a person
|
|
35
|
-
// deliberately bound a room to their home dir, that's their call, honour it.
|
|
36
|
-
if (!bound && !remembered && samePath(pick, home)) {
|
|
37
|
-
return { dir: pick, refuse: true, reason: 'home' }
|
|
38
|
-
}
|
|
39
|
-
return { dir: pick, refuse: false, fromDefault: !bound && !remembered }
|
|
40
|
-
}
|
|
1
|
+
import e from"node:path";export function resolveServeDir({bound:r,remembered:o,defaultDir:n,home:t}={}){const u=r||o||n||null;return u?r||o||!((r,o)=>{try{return!!r&&!!o&&e.resolve(r)===e.resolve(o)}catch{return!1}})(u,t)?{dir:u,refuse:!1,fromDefault:!r&&!o}:{dir:u,refuse:!0,reason:"home"}:{dir:null,refuse:!0,reason:"none"}}
|