@gotcos/glasses-server 6.27.13 → 6.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/package.json +1 -1
- package/server/index.ts +186 -0
- package/server/lib/agent-session-binding-registry.ts +1225 -0
- package/server/lib/agent-session-binding-store.ts +327 -0
- package/server/lib/agent-session-ownership-store.ts +395 -0
- package/server/lib/attached-provider-adapter.ts +1223 -0
- package/server/lib/attached-workspace.ts +197 -0
- package/server/lib/fork-thread.ts +957 -0
- package/server/lib/native-head.ts +649 -0
- package/server/lib/native-thread-id.ts +23 -0
- package/server/lib/occupancy-probes.ts +444 -0
- package/server/lib/query-job-runtime.ts +24 -0
- package/server/lib/thread-attach-capability.ts +200 -0
- package/server/lib/thread-occupancy.ts +367 -0
- package/server/routes/agent-session-bindings.ts +2024 -0
- package/server/routes/health.ts +24 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// The operational lease that says "this COS Chat is driving that desktop thread".
|
|
2
|
+
//
|
|
3
|
+
// Phase 1 of Continue Original Agent Thread. A binding is NOT historical
|
|
4
|
+
// provenance — it is a lease with a state, an expiry, and an epoch. Provenance
|
|
5
|
+
// is stamped into the job at admission and read from there forever after
|
|
6
|
+
// (plan 3.4). Reading the CURRENT binding to describe a FINISHED turn is the bug
|
|
7
|
+
// this separation exists to prevent.
|
|
8
|
+
//
|
|
9
|
+
// NAMING: this file holds the binding VALUE TYPE and its transitions. It is not
|
|
10
|
+
// a store — there is no persistence here yet, and the durable per-target epoch
|
|
11
|
+
// high-water mark that the replay defense depends on does not exist. Until it
|
|
12
|
+
// does, `priorEpoch` is caller-supplied and the anti-replay guarantee is only as
|
|
13
|
+
// good as whatever the caller reads it from. That gap is deliberate and named
|
|
14
|
+
// rather than implied.
|
|
15
|
+
//
|
|
16
|
+
// WHY AN EPOCH AND NOT JUST AN ID. The prompt queue is client-owned: up to five
|
|
17
|
+
// prompts can sit in an attached Chat holding no server state at all. If the
|
|
18
|
+
// binding expires or the user detaches, those rows are still on the phone and
|
|
19
|
+
// will eventually be submitted. Without an epoch, a prompt composed against
|
|
20
|
+
// binding N can land on a re-attach of the SAME target and execute against a
|
|
21
|
+
// conversation the user never intended.
|
|
22
|
+
//
|
|
23
|
+
// KEY ALIASING IS A REAL RISK, NOT A THEORETICAL ONE. `SAFE_ID_RE` in
|
|
24
|
+
// query-job-types.ts is `/^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/`, which permits both
|
|
25
|
+
// `:` and `/` inside a session id, so `provider + ':' + id` is forgeable. Every
|
|
26
|
+
// composite identifier in this file is LENGTH-PREFIXED, which is injective for
|
|
27
|
+
// all string content. That applies to `boundToMarker` too: an earlier version
|
|
28
|
+
// built it by dot-joining three fields sixteen lines below a comment banning
|
|
29
|
+
// exactly that, and its safety rested on the incidental fact that a targetKey
|
|
30
|
+
// never contains `.` before its first `:`. Do not reintroduce concatenation.
|
|
31
|
+
//
|
|
32
|
+
// STATES ARE AN ALLOWLIST. Only `active` may run work. An earlier version
|
|
33
|
+
// rejected `detached`/`detaching` by denylist, which silently let `staging` —
|
|
34
|
+
// the pre-commit state of the journaled Chat handoff (plan 4.8) — execute real
|
|
35
|
+
// turns against a Chat that might still be rolled back.
|
|
36
|
+
|
|
37
|
+
import { isValidNativeThreadId } from './native-thread-id.js'
|
|
38
|
+
import type { AgentProvider } from './agent-session-store.js'
|
|
39
|
+
|
|
40
|
+
export type BindingState = 'staging' | 'active' | 'detaching' | 'detached'
|
|
41
|
+
|
|
42
|
+
/** Providers that can carry a binding. Cursor is Fork-only (plan 2.5). */
|
|
43
|
+
export const BINDABLE_PROVIDERS = ['claude', 'codex'] as const
|
|
44
|
+
export type BindableProvider = (typeof BINDABLE_PROVIDERS)[number]
|
|
45
|
+
|
|
46
|
+
export interface NativeBinding {
|
|
47
|
+
bindingId: string
|
|
48
|
+
cosSessionId: string
|
|
49
|
+
/** Narrowed to the bindable subset so an unbindable provider is unrepresentable. */
|
|
50
|
+
provider: BindableProvider
|
|
51
|
+
/** Exact private native thread id. Never prefix-matched, never truncated. */
|
|
52
|
+
nativeThreadId: string
|
|
53
|
+
/** Injective mutex key. */
|
|
54
|
+
targetKey: string
|
|
55
|
+
workspaceFingerprint: string
|
|
56
|
+
sourceFingerprint: string
|
|
57
|
+
/** Opaque revision token observed at attach. Null when unsupported. */
|
|
58
|
+
nativeHeadAtAttach: string | null
|
|
59
|
+
/** Increments per attach to the same target. Positive integer. */
|
|
60
|
+
epoch: number
|
|
61
|
+
state: BindingState
|
|
62
|
+
/** Epoch ms. Ignored while pinned. */
|
|
63
|
+
expiresAt: number
|
|
64
|
+
pinnedJobs: readonly string[]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type BindingRejection =
|
|
68
|
+
| 'invalid_thread_id'
|
|
69
|
+
| 'invalid_provider'
|
|
70
|
+
| 'invalid_binding_id'
|
|
71
|
+
| 'invalid_epoch'
|
|
72
|
+
| 'invalid_ttl'
|
|
73
|
+
| 'unknown_binding'
|
|
74
|
+
| 'binding_not_active'
|
|
75
|
+
| 'binding_detached'
|
|
76
|
+
| 'binding_expired'
|
|
77
|
+
| 'stale_epoch'
|
|
78
|
+
| 'target_mismatch'
|
|
79
|
+
| 'missing_target_key'
|
|
80
|
+
| 'binding_pinned'
|
|
81
|
+
| 'terminal_state'
|
|
82
|
+
|
|
83
|
+
export interface BindingCheck {
|
|
84
|
+
ok: boolean
|
|
85
|
+
reason: BindingRejection | null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const OK: BindingCheck = { ok: true, reason: null }
|
|
89
|
+
const no = (reason: BindingRejection): BindingCheck => ({ ok: false, reason })
|
|
90
|
+
|
|
91
|
+
/** A bindingId must be safe to embed in a marker and to log. */
|
|
92
|
+
export const BINDING_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
|
|
93
|
+
|
|
94
|
+
export function isBindableProvider(value: unknown): value is BindableProvider {
|
|
95
|
+
return typeof value === 'string' && (BINDABLE_PROVIDERS as readonly string[]).includes(value)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Injective composite key: `<len>:<provider>:<len>:<threadId>`.
|
|
100
|
+
*
|
|
101
|
+
* Length-prefixing is what makes it unforgeable. With plain `a:b`, provider `x`
|
|
102
|
+
* with id `y:z` collides with provider `x:y` and id `z`. With lengths the parse
|
|
103
|
+
* is unambiguous for every possible content.
|
|
104
|
+
*
|
|
105
|
+
* NOTE: this function validates nothing — the safety property lives in the
|
|
106
|
+
* callers, all of which validate first.
|
|
107
|
+
*/
|
|
108
|
+
export function targetKey(provider: string, nativeThreadId: string): string {
|
|
109
|
+
return `${provider.length}:${provider}:${nativeThreadId.length}:${nativeThreadId}`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface CreateBindingInput {
|
|
113
|
+
bindingId: string
|
|
114
|
+
cosSessionId: string
|
|
115
|
+
provider: string
|
|
116
|
+
nativeThreadId: string
|
|
117
|
+
workspaceFingerprint: string
|
|
118
|
+
sourceFingerprint: string
|
|
119
|
+
nativeHeadAtAttach?: string | null
|
|
120
|
+
/**
|
|
121
|
+
* Highest epoch previously issued FOR THIS TARGET, from durable state.
|
|
122
|
+
*
|
|
123
|
+
* Reading this from the current in-memory binding rather than a persisted
|
|
124
|
+
* high-water mark reopens the replay window: after detach and eviction the
|
|
125
|
+
* next attach restarts at 1 and a queued row from the first attach matches.
|
|
126
|
+
*/
|
|
127
|
+
priorEpoch?: number
|
|
128
|
+
ttlMs: number
|
|
129
|
+
now: number
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type CreateBindingResult =
|
|
133
|
+
| { binding: NativeBinding; reason: null }
|
|
134
|
+
| { binding: null; reason: BindingRejection }
|
|
135
|
+
|
|
136
|
+
export function createBinding(input: CreateBindingInput): CreateBindingResult {
|
|
137
|
+
if (!isBindableProvider(input.provider)) return { binding: null, reason: 'invalid_provider' }
|
|
138
|
+
if (!isValidNativeThreadId(input.nativeThreadId)) return { binding: null, reason: 'invalid_thread_id' }
|
|
139
|
+
if (typeof input.bindingId !== 'string' || !BINDING_ID_RE.test(input.bindingId)) {
|
|
140
|
+
return { binding: null, reason: 'invalid_binding_id' }
|
|
141
|
+
}
|
|
142
|
+
const prior = input.priorEpoch ?? 0
|
|
143
|
+
if (!Number.isInteger(prior) || prior < 0) return { binding: null, reason: 'invalid_epoch' }
|
|
144
|
+
if (!Number.isFinite(input.ttlMs) || input.ttlMs <= 0) return { binding: null, reason: 'invalid_ttl' }
|
|
145
|
+
if (!Number.isFinite(input.now)) return { binding: null, reason: 'invalid_ttl' }
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
binding: {
|
|
149
|
+
bindingId: input.bindingId,
|
|
150
|
+
cosSessionId: input.cosSessionId,
|
|
151
|
+
provider: input.provider,
|
|
152
|
+
nativeThreadId: input.nativeThreadId,
|
|
153
|
+
targetKey: targetKey(input.provider, input.nativeThreadId),
|
|
154
|
+
workspaceFingerprint: input.workspaceFingerprint,
|
|
155
|
+
sourceFingerprint: input.sourceFingerprint,
|
|
156
|
+
nativeHeadAtAttach: input.nativeHeadAtAttach ?? null,
|
|
157
|
+
epoch: prior + 1,
|
|
158
|
+
state: 'staging',
|
|
159
|
+
expiresAt: input.now + input.ttlMs,
|
|
160
|
+
pinnedJobs: [],
|
|
161
|
+
},
|
|
162
|
+
reason: null,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function isTerminal(binding: NativeBinding): boolean {
|
|
167
|
+
return binding.state === 'detached' || binding.state === 'detaching'
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* staging -> active. Refused from any other state.
|
|
172
|
+
*
|
|
173
|
+
* Without this guard `activate(detach(b))` resurrects a detached binding, which
|
|
174
|
+
* QA verified against the first version.
|
|
175
|
+
*/
|
|
176
|
+
export function activate(binding: NativeBinding): { binding: NativeBinding | null; reason: BindingRejection | null } {
|
|
177
|
+
if (binding.state !== 'staging') return { binding: null, reason: 'terminal_state' }
|
|
178
|
+
return { binding: { ...binding, state: 'active' }, reason: null }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** active -> detaching. The drain step that makes `detaching` reachable. */
|
|
182
|
+
export function beginDetach(binding: NativeBinding): NativeBinding {
|
|
183
|
+
return isTerminal(binding) ? binding : { ...binding, state: 'detaching' }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function pin(binding: NativeBinding, jobId: string): NativeBinding {
|
|
187
|
+
// A terminal binding must not be re-pinned into permanent non-expiry.
|
|
188
|
+
if (isTerminal(binding) || binding.pinnedJobs.includes(jobId)) return binding
|
|
189
|
+
return { ...binding, pinnedJobs: [...binding.pinnedJobs, jobId] }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function unpin(binding: NativeBinding, jobId: string): NativeBinding {
|
|
193
|
+
return { ...binding, pinnedJobs: binding.pinnedJobs.filter(id => id !== jobId) }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function isPinned(binding: NativeBinding): boolean {
|
|
197
|
+
return binding.pinnedJobs.length > 0
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A pinned binding NEVER expires. Accepted or running work outranks the TTL.
|
|
202
|
+
*
|
|
203
|
+
* Known gap: `pinnedJobs` carries no timestamps, so a job that dies without
|
|
204
|
+
* unpinning makes the binding immortal. A reaper needs per-pin ages; this shape
|
|
205
|
+
* cannot express one.
|
|
206
|
+
*/
|
|
207
|
+
export function isExpired(binding: NativeBinding, now: number): boolean {
|
|
208
|
+
if (isPinned(binding)) return false
|
|
209
|
+
return now >= binding.expiresAt
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Extend a lease. Never shortens, and never revives a terminal binding. */
|
|
213
|
+
export function renew(binding: NativeBinding, ttlMs: number, now: number): NativeBinding {
|
|
214
|
+
if (isTerminal(binding) || !Number.isFinite(ttlMs) || !Number.isFinite(now)) return binding
|
|
215
|
+
const next = now + ttlMs
|
|
216
|
+
return next > binding.expiresAt ? { ...binding, expiresAt: next } : binding
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Detach is refused while work is active (plan 3.4). */
|
|
220
|
+
export function canDetach(binding: NativeBinding): BindingCheck {
|
|
221
|
+
return isPinned(binding) ? no('binding_pinned') : OK
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Detach, honoring `canDetach`.
|
|
226
|
+
*
|
|
227
|
+
* The first version exported an unguarded mutator next to its guard, so
|
|
228
|
+
* `detach(pinnedBinding)` silently dropped the pins and orphaned the live job
|
|
229
|
+
* the guard existed to protect.
|
|
230
|
+
*/
|
|
231
|
+
export function detach(
|
|
232
|
+
binding: NativeBinding,
|
|
233
|
+
): { binding: NativeBinding; reason: null } | { binding: null; reason: BindingRejection } {
|
|
234
|
+
const gate = canDetach(binding)
|
|
235
|
+
if (!gate.ok) return { binding: null, reason: gate.reason! }
|
|
236
|
+
return { binding: { ...binding, state: 'detached', pinnedJobs: [] }, reason: null }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Force-detach after an explicit user cancel that reached terminal state. */
|
|
240
|
+
export function forceDetach(binding: NativeBinding): NativeBinding {
|
|
241
|
+
return { ...binding, state: 'detached', pinnedJobs: [] }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The single gate every execution path must clear.
|
|
246
|
+
*
|
|
247
|
+
* `checkQueuedPrompt` and `verifyBoundTo` both delegate here so they cannot
|
|
248
|
+
* disagree — the first version had the full set of checks in one and a bare
|
|
249
|
+
* string comparison in the other, so a detached or expired binding passed
|
|
250
|
+
* `verifyBoundTo` cleanly.
|
|
251
|
+
*/
|
|
252
|
+
export function assertUsable(binding: NativeBinding | null, now: number): BindingCheck {
|
|
253
|
+
if (!binding) return no('unknown_binding')
|
|
254
|
+
if (binding.state === 'detached' || binding.state === 'detaching') return no('binding_detached')
|
|
255
|
+
// Allowlist: only `active` runs work. `staging` is pre-commit (plan 4.8).
|
|
256
|
+
if (binding.state !== 'active') return no('binding_not_active')
|
|
257
|
+
if (isExpired(binding, now)) return no('binding_expired')
|
|
258
|
+
return OK
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export interface QueuedPromptClaim {
|
|
262
|
+
bindingId: string
|
|
263
|
+
epoch: number
|
|
264
|
+
/** Required. The strongest cross-target guard must not be opt-in. */
|
|
265
|
+
targetKey: string
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Can this client-queued prompt still run?
|
|
270
|
+
*
|
|
271
|
+
* The queue lives on the phone and holds no server state, so a row can outlive
|
|
272
|
+
* the binding it was composed against. Every rejection here is a row that would
|
|
273
|
+
* otherwise either 409 opaquely or execute as a plain COS turn — the silent
|
|
274
|
+
* Continue-to-ordinary downgrade the product contract bans (plan 4.2).
|
|
275
|
+
*/
|
|
276
|
+
export function checkQueuedPrompt(
|
|
277
|
+
claim: QueuedPromptClaim,
|
|
278
|
+
binding: NativeBinding | null,
|
|
279
|
+
now: number,
|
|
280
|
+
): BindingCheck {
|
|
281
|
+
if (!binding) return no('unknown_binding')
|
|
282
|
+
if (claim.bindingId !== binding.bindingId) return no('unknown_binding')
|
|
283
|
+
if (typeof claim.targetKey !== 'string' || claim.targetKey.length === 0) return no('missing_target_key')
|
|
284
|
+
|
|
285
|
+
// State before epoch: detached is the more actionable answer for a user whose
|
|
286
|
+
// queue drained after they detached.
|
|
287
|
+
const usable = assertUsable(binding, now)
|
|
288
|
+
if (!usable.ok && usable.reason !== 'binding_expired') return usable
|
|
289
|
+
|
|
290
|
+
// Epoch before expiry: a re-attach is a different and more dangerous fault
|
|
291
|
+
// than a timeout, and collapsing it into 'expired' hides that the target moved.
|
|
292
|
+
if (claim.epoch !== binding.epoch) return no('stale_epoch')
|
|
293
|
+
if (claim.targetKey !== binding.targetKey) return no('target_mismatch')
|
|
294
|
+
return usable.ok ? OK : usable
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Opaque marker persisted INTO the request so a re-admission is recognizable.
|
|
299
|
+
*
|
|
300
|
+
* Without it, a re-admission through the generic route carries no attachment
|
|
301
|
+
* fields at all — the binding lives in the route path and 4.2 forbids the client
|
|
302
|
+
* sending execution fields — so the generic route's rejection cannot fire and an
|
|
303
|
+
* attached turn degrades silently into an ordinary one.
|
|
304
|
+
*
|
|
305
|
+
* Length-prefixed for the same reason `targetKey` is.
|
|
306
|
+
*/
|
|
307
|
+
export function boundToMarker(binding: NativeBinding): string {
|
|
308
|
+
const epoch = String(binding.epoch)
|
|
309
|
+
return [
|
|
310
|
+
`${binding.bindingId.length}:${binding.bindingId}`,
|
|
311
|
+
`${epoch.length}:${epoch}`,
|
|
312
|
+
`${binding.targetKey.length}:${binding.targetKey}`,
|
|
313
|
+
].join('')
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Verify a marker AND that the binding it names may still run work. */
|
|
317
|
+
export function verifyBoundTo(marker: unknown, binding: NativeBinding | null, now: number): BindingCheck {
|
|
318
|
+
const usable = assertUsable(binding, now)
|
|
319
|
+
if (!usable.ok) return usable
|
|
320
|
+
if (typeof marker !== 'string' || marker !== boundToMarker(binding!)) return no('target_mismatch')
|
|
321
|
+
return OK
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Is this request an attached turn at all? Used to reject it on generic routes. */
|
|
325
|
+
export function carriesBoundTo(request: Record<string, unknown> | null | undefined): boolean {
|
|
326
|
+
return typeof request?.boundTo === 'string' && request.boundTo.length > 0
|
|
327
|
+
}
|