@gotcos/glasses-server 6.82.0 → 6.83.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 +81 -0
- package/docs/work-orchestrator.md +184 -0
- package/package.json +4 -1
- package/server/index.ts +12 -0
- package/server/lib/claude-bridge.ts +4 -0
- package/server/lib/query-job-coordinator.ts +5 -1
- package/server/lib/work-chat.ts +76 -9
- package/server/lib/work-lens-rounds.ts +445 -0
- package/server/lib/work-orchestrator/discovery.ts +99 -0
- package/server/lib/work-orchestrator/input.ts +109 -0
- package/server/lib/work-orchestrator/pickup.ts +85 -0
- package/server/lib/work-orchestrator/plan-log.ts +38 -0
- package/server/lib/work-orchestrator/redact.ts +41 -0
- package/server/lib/work-orchestrator/runtime.ts +74 -0
- package/server/lib/work-orchestrator/session-log.ts +114 -0
- package/server/lib/work-orchestrator/snapshot.ts +87 -0
- package/server/lib/work-orchestrator/tag-index.ts +272 -0
- package/server/lib/work-orchestrator/tags.ts +52 -0
- package/server/lib/work-orchestrator/usage-events.ts +30 -0
- package/server/lib/work-orchestrator/usage.ts +341 -0
- package/server/routes/work-chat.ts +54 -11
- package/server/routes/work-lens-rounds.ts +179 -0
- package/server/routes/work-orchestrator.ts +71 -0
- package/server/scripts/work-lens-rounds-mutation-gate.mjs +151 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.83.0 (M3 planner input, canary C1): how much PLAN usage each provider has left (Miles, Q2: plan usage only; a key
|
|
3
|
+
* lane is never a silent fallback). Read without spending any of it.
|
|
4
|
+
*
|
|
5
|
+
* Claude `claude -p` stream-json control request `get_usage` with `skip_behaviors` (no turn, no transcript).
|
|
6
|
+
* Codex `codex app-server` JSON-RPC `account/rateLimits/read` with `excludeResetCreditDetails`. Never sends
|
|
7
|
+
* `supportsLunaReserve` (it records experiment exposure); never calls ConsumeAccountRateLimitResetCredit.
|
|
8
|
+
* Cursor unknown. Its only usage reader is the interactive TUI (which auto-updates the CLI) or a Keychain token;
|
|
9
|
+
* Miles has approved neither, so the server reads neither.
|
|
10
|
+
*
|
|
11
|
+
* Read ON DEMAND only (6.83.0 release decision, Miles has not approved a background poll for every user): a plan
|
|
12
|
+
* input (`POST /api/work/orchestrator/input`, the moment a fork or a new session is chosen) re-reads a provider whose
|
|
13
|
+
* reading is older than 2 minutes; `GET /api/work/orchestrator/usage` re-reads one older than 10 minutes (2 with
|
|
14
|
+
* `refresh=1`). Nothing reads usage on a timer unless the owner opts in with `COS_WORK_USAGE_POLL=1` (every 10 minutes).
|
|
15
|
+
* Passive refresh needs no probe: the `rate_limit_event` frames the server's own plan-lane Claude jobs already receive.
|
|
16
|
+
* A `provider_limit` failure marks the provider exhausted until the reset it states (or one hour), and that beats any
|
|
17
|
+
* cached percentage.
|
|
18
|
+
*
|
|
19
|
+
* How it stops: no timer by default; three failed probes in a row pause that provider's probe for an hour. Each probe
|
|
20
|
+
* runs in a fresh empty directory with hooks and MCP off, and is killed by its exact PID after 15 seconds.
|
|
21
|
+
*/
|
|
22
|
+
import { spawn } from 'node:child_process'
|
|
23
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
24
|
+
import { tmpdir } from 'node:os'
|
|
25
|
+
import { dirname, join } from 'node:path'
|
|
26
|
+
import { atomicWriteFileSync } from '../atomic-fs.js'
|
|
27
|
+
import { resolveProviderBinary } from '../provider-binary.js'
|
|
28
|
+
|
|
29
|
+
export type UsageProvider = 'claude' | 'codex' | 'cursor'
|
|
30
|
+
export const USAGE_PROVIDERS: UsageProvider[] = ['claude', 'codex', 'cursor']
|
|
31
|
+
/** The opt-in poll's period (`COS_WORK_USAGE_POLL=1` only). */
|
|
32
|
+
export const USAGE_POLL_MS = 10 * 60_000
|
|
33
|
+
/** Before a plan (it may fork or start a session): a reading older than this is re-read. */
|
|
34
|
+
export const USAGE_FRESH_MS = 2 * 60_000
|
|
35
|
+
/** An on-demand usage read (GET /usage) answers from a reading younger than this. */
|
|
36
|
+
export const USAGE_CACHE_MS = 10 * 60_000
|
|
37
|
+
export const USAGE_STALE_MS = 25 * 60_000
|
|
38
|
+
export const USAGE_PROBE_TIMEOUT_MS = 15_000
|
|
39
|
+
export const USAGE_BREAKER_FAILURES = 3
|
|
40
|
+
export const USAGE_BREAKER_MS = 60 * 60_000
|
|
41
|
+
export const USAGE_LIMIT_DEFAULT_MS = 60 * 60_000
|
|
42
|
+
export const USAGE_LOW_HEADROOM = 15
|
|
43
|
+
|
|
44
|
+
export interface UsageWindow { kind: string; usedPct: number; resetsAt: string | null; durationMins?: number }
|
|
45
|
+
export interface UsageReading {
|
|
46
|
+
plan: string | null
|
|
47
|
+
windows: UsageWindow[]
|
|
48
|
+
/** Codex `ordinaryUsageAllowed`; Claude false only when a window reports a lock. Null: not stated. */
|
|
49
|
+
allowed: boolean | null
|
|
50
|
+
/** Claude `rate_limits_available: false` (an API key or a third-party login): no plan limits to read. */
|
|
51
|
+
available: boolean
|
|
52
|
+
}
|
|
53
|
+
export interface UsageEntry {
|
|
54
|
+
provider: UsageProvider
|
|
55
|
+
reading: UsageReading | null
|
|
56
|
+
observedAt: number | null
|
|
57
|
+
source: 'probe' | 'passive' | 'none'
|
|
58
|
+
lastError: string | null
|
|
59
|
+
failures: number
|
|
60
|
+
pausedUntil: number | null
|
|
61
|
+
/** Exhausted until this time, from a provider_limit failure or a rejected rate-limit frame. */
|
|
62
|
+
limitUntil: number | null
|
|
63
|
+
limitSource: string | null
|
|
64
|
+
warning: boolean
|
|
65
|
+
}
|
|
66
|
+
export type UsageState = 'ok' | 'low' | 'exhausted' | 'unknown'
|
|
67
|
+
/** What the planner sees: one line per provider. */
|
|
68
|
+
export interface UsageView { provider: UsageProvider; state: UsageState; headroomPct: number | null; resetsAt: string | null; ageSec: number | null; source: string; plan: string | null; stale: boolean; note: string }
|
|
69
|
+
|
|
70
|
+
/** The windows that bind: Claude's 5-hour session and weekly all-models; every Codex window; Cursor's included. */
|
|
71
|
+
const BINDING: Record<UsageProvider, (kind: string) => boolean> = {
|
|
72
|
+
claude: kind => kind === 'session' || kind === 'weekly_all',
|
|
73
|
+
codex: () => true,
|
|
74
|
+
cursor: kind => kind === 'included',
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const num = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
|
|
78
|
+
const iso = (s: number) => new Date(s * 1000).toISOString()
|
|
79
|
+
|
|
80
|
+
/** Claude `get_usage` response (`response.response`). Classify on `kind`, never on a label. Null when the shape is not one we know. */
|
|
81
|
+
export function parseClaudeUsage(r: unknown): UsageReading | null {
|
|
82
|
+
if (!r || typeof r !== 'object') return null
|
|
83
|
+
const v = r as Record<string, any>
|
|
84
|
+
if (v.rate_limits_available !== true) return typeof v.rate_limits_available === 'boolean' ? { plan: typeof v.subscription_type === 'string' ? v.subscription_type : null, windows: [], allowed: null, available: false } : null
|
|
85
|
+
const limits = v.rate_limits?.limits
|
|
86
|
+
if (!Array.isArray(limits)) return null
|
|
87
|
+
const windows: UsageWindow[] = []
|
|
88
|
+
let locked = false
|
|
89
|
+
for (const l of limits) {
|
|
90
|
+
if (!l || typeof l.kind !== 'string' || !num(l.percent) || l.percent < 0 || l.percent > 1000) return null
|
|
91
|
+
windows.push({ kind: l.kind, usedPct: l.percent, resetsAt: typeof l.resets_at === 'string' && !Number.isNaN(Date.parse(l.resets_at)) ? new Date(Date.parse(l.resets_at)).toISOString() : null })
|
|
92
|
+
if (l.severity === 'locked' || l.severity === 'blocked') locked = true
|
|
93
|
+
}
|
|
94
|
+
return { plan: typeof v.subscription_type === 'string' ? v.subscription_type : null, windows, allowed: locked ? false : null, available: true }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Codex `account/rateLimits/read` result. `ordinaryUsageAllowed` is authoritative over percentages (its schema says so). */
|
|
98
|
+
export function parseCodexUsage(r: unknown): UsageReading | null {
|
|
99
|
+
if (!r || typeof r !== 'object') return null
|
|
100
|
+
const v = r as Record<string, any>
|
|
101
|
+
const limits = v.rateLimitsByLimitId?.codex ?? v.rateLimits
|
|
102
|
+
if (!limits || typeof limits !== 'object') return null
|
|
103
|
+
const windows: UsageWindow[] = []
|
|
104
|
+
for (const [kind, w] of [['primary', limits.primary], ['secondary', limits.secondary]] as const) {
|
|
105
|
+
if (w == null) continue
|
|
106
|
+
if (typeof w !== 'object' || !num(w.usedPercent)) return null
|
|
107
|
+
windows.push({ kind, usedPct: w.usedPercent, resetsAt: num(w.resetsAt) ? iso(w.resetsAt) : null, ...(num(w.windowDurationMins) ? { durationMins: w.windowDurationMins } : {}) })
|
|
108
|
+
}
|
|
109
|
+
const reached = limits.rateLimitReachedType != null || limits.spendControlReached === true
|
|
110
|
+
const allowed = typeof v.ordinaryUsageAllowed === 'boolean' ? v.ordinaryUsageAllowed && !reached : reached ? false : null
|
|
111
|
+
return { plan: typeof limits.planType === 'string' ? limits.planType : null, windows, allowed, available: true }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A reset time named in a provider's own limit text ("resets 2026-10-21T05:50Z", "resets in 3h", "try again in 20 minutes"). */
|
|
115
|
+
export function parseLimitReset(message: string, now: number): number | null {
|
|
116
|
+
const isoMatch = /\b(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2}))/.exec(message)
|
|
117
|
+
if (isoMatch && !Number.isNaN(Date.parse(isoMatch[1]!))) return Date.parse(isoMatch[1]!)
|
|
118
|
+
const rel = /\b(?:resets?|try again|retry)\s+in\s+(\d+(?:\.\d+)?)\s*(h|hr|hrs|hours?|m|min|mins|minutes?)\b/i.exec(message)
|
|
119
|
+
if (rel) return now + Number(rel[1]) * (/^h/i.test(rel[2]!) ? 3_600_000 : 60_000)
|
|
120
|
+
const epoch = /\bresets?(?:At)?["':\s]+(\d{10})\b/.exec(message)
|
|
121
|
+
if (epoch) return Number(epoch[1]) * 1000
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function headroomOf(provider: UsageProvider, reading: UsageReading | null): number | null {
|
|
126
|
+
if (!reading || !reading.available) return null
|
|
127
|
+
const bind = reading.windows.filter(w => BINDING[provider](w.kind))
|
|
128
|
+
if (!bind.length) return null
|
|
129
|
+
return Math.max(0, Math.round(100 - Math.max(...bind.map(w => w.usedPct))))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function viewOf(e: UsageEntry, now: number): UsageView {
|
|
133
|
+
const ageSec = e.observedAt ? Math.round((now - e.observedAt) / 1000) : null
|
|
134
|
+
const stale = !e.observedAt || now - e.observedAt > USAGE_STALE_MS
|
|
135
|
+
const headroom = headroomOf(e.provider, e.reading)
|
|
136
|
+
const bind = (e.reading?.windows ?? []).filter(w => BINDING[e.provider](w.kind))
|
|
137
|
+
const tightest = bind.length ? bind.reduce((a, b) => (b.usedPct > a.usedPct ? b : a)) : null
|
|
138
|
+
const base = { provider: e.provider, ageSec, source: e.source, plan: e.reading?.plan ?? null, stale }
|
|
139
|
+
// A stated limit beats any percentage (canary C1 rule), until the time it names.
|
|
140
|
+
if (e.limitUntil && e.limitUntil > now) {
|
|
141
|
+
return { ...base, state: 'exhausted', headroomPct: 0, resetsAt: new Date(e.limitUntil).toISOString(), source: e.limitSource ?? 'provider_limit', note: 'plan limit reached' }
|
|
142
|
+
}
|
|
143
|
+
if (e.reading?.allowed === false) return { ...base, state: 'exhausted', headroomPct: 0, resetsAt: tightest?.resetsAt ?? null, note: 'the provider says ordinary usage is not allowed now' }
|
|
144
|
+
if (e.provider === 'cursor' && !e.reading) return { ...base, state: 'unknown', headroomPct: null, resetsAt: null, note: 'not read: Cursor usage needs its app or a Keychain read, neither approved' }
|
|
145
|
+
if (!e.reading || headroom === null) return { ...base, state: 'unknown', headroomPct: null, resetsAt: null, note: e.reading && !e.reading.available ? 'no plan limits on this login' : e.lastError ? `not read: ${e.lastError}` : 'not read yet' }
|
|
146
|
+
const state: UsageState = headroom <= 0 ? 'exhausted' : headroom < USAGE_LOW_HEADROOM || e.warning ? 'low' : 'ok'
|
|
147
|
+
return { ...base, state, headroomPct: headroom, resetsAt: tightest?.resetsAt ?? null, note: stale ? 'reading is old' : '' }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The background poll runs only when the owner opted in: `COS_WORK_USAGE_POLL=1`, nothing else. */
|
|
151
|
+
export function usagePollOptedIn(env: NodeJS.ProcessEnv = process.env): boolean { return env.COS_WORK_USAGE_POLL === '1' }
|
|
152
|
+
|
|
153
|
+
export interface UsageProbes {
|
|
154
|
+
claude: () => Promise<UsageReading>
|
|
155
|
+
codex: () => Promise<UsageReading>
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface UsageMonitorOptions { path?: string; probes?: Partial<UsageProbes>; now?: () => number; pollEnabled?: boolean }
|
|
159
|
+
|
|
160
|
+
export class UsageMonitor {
|
|
161
|
+
private entries: Record<UsageProvider, UsageEntry>
|
|
162
|
+
private inflight: Partial<Record<UsageProvider, Promise<void>>> = {}
|
|
163
|
+
private timer: NodeJS.Timeout | null = null
|
|
164
|
+
private readonly now: () => number
|
|
165
|
+
private readonly probes: UsageProbes
|
|
166
|
+
constructor(private readonly options: UsageMonitorOptions = {}) {
|
|
167
|
+
this.now = options.now ?? Date.now
|
|
168
|
+
this.probes = { claude: options.probes?.claude ?? probeClaudeUsage, codex: options.probes?.codex ?? probeCodexUsage }
|
|
169
|
+
const blank = (provider: UsageProvider): UsageEntry => ({ provider, reading: null, observedAt: null, source: 'none', lastError: null, failures: 0, pausedUntil: null, limitUntil: null, limitSource: null, warning: false })
|
|
170
|
+
this.entries = { claude: blank('claude'), codex: blank('codex'), cursor: blank('cursor') }
|
|
171
|
+
if (options.path) {
|
|
172
|
+
try {
|
|
173
|
+
const saved = JSON.parse(readFileSync(options.path, 'utf8'))
|
|
174
|
+
for (const p of USAGE_PROVIDERS) if (saved?.[p]?.provider === p) this.entries[p] = { ...this.entries[p], ...saved[p], failures: 0, pausedUntil: null }
|
|
175
|
+
} catch { /* none saved yet */ }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
private save(): void {
|
|
179
|
+
if (!this.options.path) return
|
|
180
|
+
try { mkdirSync(dirname(this.options.path), { recursive: true, mode: 0o700 }); atomicWriteFileSync(this.options.path, JSON.stringify(this.entries)) } catch { /* cache only */ }
|
|
181
|
+
}
|
|
182
|
+
entry(p: UsageProvider): UsageEntry { return this.entries[p] }
|
|
183
|
+
views(): UsageView[] { const now = this.now(); return USAGE_PROVIDERS.map(p => viewOf(this.entries[p], now)) }
|
|
184
|
+
/** Probe one provider now (shares a probe already running). Failures count toward the breaker; never throws. */
|
|
185
|
+
refresh(p: 'claude' | 'codex'): Promise<void> {
|
|
186
|
+
if (this.inflight[p]) return this.inflight[p]!
|
|
187
|
+
const e = this.entries[p]
|
|
188
|
+
if (e.pausedUntil && e.pausedUntil > this.now()) return Promise.resolve()
|
|
189
|
+
const run = (async () => {
|
|
190
|
+
try {
|
|
191
|
+
const reading = await this.probes[p]()
|
|
192
|
+
e.reading = reading; e.observedAt = this.now(); e.source = 'probe'; e.lastError = null; e.failures = 0; e.pausedUntil = null; e.warning = false
|
|
193
|
+
// A fresh reading that says usage is allowed again clears an older limit only when its own reset passed.
|
|
194
|
+
if (e.limitUntil && e.limitUntil <= this.now()) { e.limitUntil = null; e.limitSource = null }
|
|
195
|
+
} catch (error) {
|
|
196
|
+
e.lastError = String((error as Error)?.message ?? error).slice(0, 120)
|
|
197
|
+
e.failures++
|
|
198
|
+
if (e.failures >= USAGE_BREAKER_FAILURES) {
|
|
199
|
+
e.pausedUntil = this.now() + USAGE_BREAKER_MS
|
|
200
|
+
console.warn(`[work-orchestrator] usage probe ${p} paused for 1 h after ${e.failures} failures: ${e.lastError}`)
|
|
201
|
+
}
|
|
202
|
+
} finally { delete this.inflight[p]; this.save() }
|
|
203
|
+
})()
|
|
204
|
+
this.inflight[p] = run
|
|
205
|
+
return run
|
|
206
|
+
}
|
|
207
|
+
/** Re-read any provider whose reading is older than `maxAgeMs` (default 2 minutes), waiting at most `waitMs`. */
|
|
208
|
+
async ensureFresh(maxAgeMs = USAGE_FRESH_MS, waitMs = 16_000): Promise<void> {
|
|
209
|
+
const now = this.now()
|
|
210
|
+
const due = (['claude', 'codex'] as const).filter(p => !this.entries[p].observedAt || now - this.entries[p].observedAt! > maxAgeMs)
|
|
211
|
+
if (!due.length) return
|
|
212
|
+
await Promise.race([Promise.all(due.map(p => this.refresh(p))), new Promise(r => setTimeout(r, waitMs).unref?.())])
|
|
213
|
+
}
|
|
214
|
+
/** Before a plan (POST /input), which may fork or start a new session: re-read what is older than 2 minutes. */
|
|
215
|
+
async forPlan(): Promise<UsageView[]> { await this.ensureFresh(USAGE_FRESH_MS); return this.views() }
|
|
216
|
+
/** On demand (GET /usage): answer from a reading younger than 10 minutes; `refresh` tightens that to 2. */
|
|
217
|
+
async onDemand(refresh = false): Promise<UsageView[]> { await this.ensureFresh(refresh ? USAGE_FRESH_MS : USAGE_CACHE_MS); return this.views() }
|
|
218
|
+
/** A provider refused a plan-lane turn with its limit text. Never called for a key-lane turn. */
|
|
219
|
+
noteLimit(p: UsageProvider, message: string, source = 'provider_limit'): void {
|
|
220
|
+
const now = this.now()
|
|
221
|
+
const until = parseLimitReset(message, now) ?? now + USAGE_LIMIT_DEFAULT_MS
|
|
222
|
+
const e = this.entries[p]
|
|
223
|
+
e.limitUntil = Math.max(e.limitUntil ?? 0, until > now ? until : now + USAGE_LIMIT_DEFAULT_MS); e.limitSource = source
|
|
224
|
+
console.log(`[work-orchestrator] usage ${p} marked exhausted until ${new Date(e.limitUntil).toISOString()} (${source})`)
|
|
225
|
+
this.save()
|
|
226
|
+
}
|
|
227
|
+
/** A `rate_limit_event` frame from one of this server's own plan-lane Claude jobs (C1: free passive refresh). */
|
|
228
|
+
noteClaudeRateLimitEvent(frame: unknown): void {
|
|
229
|
+
const info = (frame as any)?.rate_limit_info
|
|
230
|
+
if (!info || typeof info !== 'object' || typeof info.status !== 'string') return
|
|
231
|
+
const e = this.entries.claude
|
|
232
|
+
if (info.status === 'rejected') {
|
|
233
|
+
const resetMs = num(info.resetsAt) ? info.resetsAt * 1000 : this.now() + USAGE_LIMIT_DEFAULT_MS
|
|
234
|
+
e.limitUntil = Math.max(e.limitUntil ?? 0, resetMs); e.limitSource = 'rate_limit_event'
|
|
235
|
+
} else if (info.status === 'allowed_warning') e.warning = true
|
|
236
|
+
else if (info.status === 'allowed') { e.warning = false; if (e.limitSource === 'rate_limit_event' && (e.limitUntil ?? 0) <= this.now()) e.limitUntil = null }
|
|
237
|
+
if (e.source === 'none') e.source = 'passive'
|
|
238
|
+
this.save()
|
|
239
|
+
}
|
|
240
|
+
/** The opt-in background poll. Off unless `pollEnabled` is true or the env says `COS_WORK_USAGE_POLL=1`. */
|
|
241
|
+
start(env: NodeJS.ProcessEnv = process.env): void {
|
|
242
|
+
if (this.timer || !(this.options.pollEnabled ?? usagePollOptedIn(env))) return
|
|
243
|
+
const tick = () => { void this.refresh('claude'); void this.refresh('codex') }
|
|
244
|
+
this.timer = setInterval(tick, USAGE_POLL_MS)
|
|
245
|
+
this.timer.unref?.()
|
|
246
|
+
setTimeout(tick, 30_000).unref?.()
|
|
247
|
+
}
|
|
248
|
+
stop(): void { if (this.timer) clearInterval(this.timer); this.timer = null }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ─── The real probes ───
|
|
252
|
+
|
|
253
|
+
interface LineChild { pid?: number; write(line: string): void; end(): void; kill(): void; onLine(fn: (o: Record<string, any>) => void): void; onExit(fn: (code: number | null) => void): void }
|
|
254
|
+
|
|
255
|
+
function spawnLines(bin: string, args: string[], cwd: string): LineChild {
|
|
256
|
+
const env = { ...process.env }
|
|
257
|
+
delete env.CLAUDECODE
|
|
258
|
+
const child = spawn(bin, args, { cwd, env, stdio: ['pipe', 'pipe', 'ignore'] })
|
|
259
|
+
let buffer = ''
|
|
260
|
+
const lineFns: Array<(o: Record<string, any>) => void> = []
|
|
261
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
262
|
+
buffer += chunk.toString('utf8')
|
|
263
|
+
let nl
|
|
264
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
265
|
+
const line = buffer.slice(0, nl); buffer = buffer.slice(nl + 1)
|
|
266
|
+
try { const o = JSON.parse(line); if (o && typeof o === 'object') for (const fn of lineFns) fn(o) } catch { /* not JSON */ }
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
child.stdin.on('error', () => {})
|
|
270
|
+
return {
|
|
271
|
+
pid: child.pid,
|
|
272
|
+
write: line => { try { child.stdin.write(line + '\n') } catch { /* closed */ } },
|
|
273
|
+
end: () => { try { child.stdin.end() } catch { /* closed */ } },
|
|
274
|
+
// Exact PID: this child, never a pattern.
|
|
275
|
+
kill: () => { try { if (child.exitCode === null) child.kill('SIGTERM') } catch { /* gone */ } },
|
|
276
|
+
onLine: fn => { lineFns.push(fn) },
|
|
277
|
+
onExit: fn => { child.on('exit', fn); child.on('error', () => fn(null)) },
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export type SpawnLines = (bin: string, args: string[], cwd: string) => LineChild
|
|
282
|
+
|
|
283
|
+
/** The stdin lines each probe sends, in order. Pinned by tests: no luna reserve, no reset-credit call. */
|
|
284
|
+
export const CLAUDE_USAGE_LINES = [
|
|
285
|
+
{ type: 'control_request', request_id: 'cos_init', request: { subtype: 'initialize' } },
|
|
286
|
+
{ type: 'control_request', request_id: 'cos_usage', request: { subtype: 'get_usage', skip_behaviors: true } },
|
|
287
|
+
]
|
|
288
|
+
export const CODEX_INIT = { id: 1, method: 'initialize', params: { clientInfo: { name: 'cos', title: null, version: '6.83.0' }, capabilities: null } }
|
|
289
|
+
export const CODEX_READ = { id: 2, method: 'account/rateLimits/read', params: { excludeResetCreditDetails: true } }
|
|
290
|
+
|
|
291
|
+
function withScratch<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
292
|
+
const dir = mkdtempSync(join(tmpdir(), 'cos-usage-'))
|
|
293
|
+
return fn(dir).finally(() => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* best effort */ } })
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function probeClaudeUsage(spawnFn: SpawnLines = spawnLines, timeoutMs = USAGE_PROBE_TIMEOUT_MS): Promise<UsageReading> {
|
|
297
|
+
const bin = resolveProviderBinary('claude')
|
|
298
|
+
return withScratch(dir => new Promise((resolve, reject) => {
|
|
299
|
+
const child = spawnFn(bin.ok ? bin.path : 'claude', ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
|
|
300
|
+
'--strict-mcp-config', '--settings', JSON.stringify({ disableAllHooks: true })], dir)
|
|
301
|
+
let done = false
|
|
302
|
+
const finish = (err: Error | null, value?: UsageReading) => {
|
|
303
|
+
if (done) return
|
|
304
|
+
done = true; clearTimeout(timer); child.end(); setTimeout(() => child.kill(), 2000).unref?.()
|
|
305
|
+
if (err) reject(err); else resolve(value!)
|
|
306
|
+
}
|
|
307
|
+
const timer = setTimeout(() => { child.kill(); finish(new Error('timed out')) }, timeoutMs)
|
|
308
|
+
child.onLine(o => {
|
|
309
|
+
if (o.type !== 'control_response' || o.response?.request_id !== 'cos_usage') return
|
|
310
|
+
if (o.response?.subtype !== 'success') return finish(new Error('get_usage refused'))
|
|
311
|
+
const reading = parseClaudeUsage(o.response.response)
|
|
312
|
+
if (reading) finish(null, reading); else finish(new Error('unknown get_usage shape'))
|
|
313
|
+
})
|
|
314
|
+
child.onExit(code => finish(new Error(`exited ${code ?? 'on error'} before answering`)))
|
|
315
|
+
for (const line of CLAUDE_USAGE_LINES) child.write(JSON.stringify(line))
|
|
316
|
+
}))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function probeCodexUsage(spawnFn: SpawnLines = spawnLines, timeoutMs = USAGE_PROBE_TIMEOUT_MS): Promise<UsageReading> {
|
|
320
|
+
const bin = resolveProviderBinary('codex')
|
|
321
|
+
if (!bin.ok) return Promise.reject(new Error('codex not found'))
|
|
322
|
+
return withScratch(dir => new Promise((resolve, reject) => {
|
|
323
|
+
const child = spawnFn(bin.path, ['app-server'], dir)
|
|
324
|
+
let done = false
|
|
325
|
+
const finish = (err: Error | null, value?: UsageReading) => {
|
|
326
|
+
if (done) return
|
|
327
|
+
done = true; clearTimeout(timer); child.end(); setTimeout(() => child.kill(), 2000).unref?.()
|
|
328
|
+
if (err) reject(err); else resolve(value!)
|
|
329
|
+
}
|
|
330
|
+
const timer = setTimeout(() => { child.kill(); finish(new Error('timed out')) }, timeoutMs)
|
|
331
|
+
child.onLine(o => {
|
|
332
|
+
if (o.id === 1 && !done) { child.write(JSON.stringify({ method: 'initialized' })); child.write(JSON.stringify(CODEX_READ)); return }
|
|
333
|
+
if (o.id !== 2) return
|
|
334
|
+
if (o.error) return finish(new Error('rateLimits/read refused'))
|
|
335
|
+
const reading = parseCodexUsage(o.result)
|
|
336
|
+
if (reading) finish(null, reading); else finish(new Error('unknown rateLimits shape'))
|
|
337
|
+
})
|
|
338
|
+
child.onExit(code => finish(new Error(`exited ${code ?? 'on error'} before answering`)))
|
|
339
|
+
child.write(JSON.stringify(CODEX_INIT))
|
|
340
|
+
}))
|
|
341
|
+
}
|
|
@@ -1,33 +1,76 @@
|
|
|
1
1
|
import { Router } from 'express'
|
|
2
|
-
import { parseWorkChatInput, parseWorkChatAnswer, WORK_CHAT_SYSTEM, WORK_CHAT_PLANNERS, WORK_CHAT_EFFORTS, WORK_CHAT_DEFAULT_EFFORT,
|
|
3
|
-
WORK_CHAT_OUTPUT_TOKENS, WORK_CHAT_THINKING_TOKENS, WORK_CHAT_DEADLINE_MS, WORK_CHAT_MAX_ASSIGNMENTS, WORK_CHAT_MAX_ITEMS, WORK_CHAT_MAX_REFERENCES
|
|
2
|
+
import { parseWorkChatInput, parseWorkChatAnswer, WORK_CHAT_SYSTEM, WORK_CHAT_ORCHESTRATOR_RULES, WORK_CHAT_PLANNERS, WORK_CHAT_EFFORTS, WORK_CHAT_DEFAULT_EFFORT,
|
|
3
|
+
WORK_CHAT_OUTPUT_TOKENS, WORK_CHAT_ORCHESTRATOR_OUTPUT_TOKENS, WORK_CHAT_THINKING_TOKENS, WORK_CHAT_DEADLINE_MS, WORK_CHAT_MAX_ASSIGNMENTS, WORK_CHAT_MAX_ITEMS, WORK_CHAT_MAX_REFERENCES,
|
|
4
|
+
WORK_CHAT_BODY_BYTES, WORK_CHAT_ORCHESTRATOR_BODY_BYTES, type WorkChatInput, type WorkChatAnswer } from '../lib/work-chat.js'
|
|
4
5
|
import { runMeetingAskModel } from '../lib/meeting-ask-runner.js'
|
|
5
6
|
import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
|
|
6
|
-
|
|
7
|
+
import { appendPlanLog, type PlanLogRow } from '../lib/work-orchestrator/plan-log.js'
|
|
8
|
+
import { PLAN_LOG_PATH } from '../lib/work-orchestrator/runtime.js'
|
|
9
|
+
import { defaultLensRoundStore } from '../lib/work-lens-rounds.js'
|
|
10
|
+
|
|
11
|
+
/** 6.83.0: one plan's log row. Ids and enums only (plan-log.ts). */
|
|
12
|
+
export function planLogRow(input: WorkChatInput, outcome: PlanLogRow['outcome'], answer: WorkChatAnswer | null, ms: number, now = new Date()): PlanLogRow {
|
|
13
|
+
return {
|
|
14
|
+
at: now.toISOString(), workID: input.workID, mode: input.mode, planner: input.planner ?? 'sonnet', effort: input.effort ?? WORK_CHAT_DEFAULT_EFFORT,
|
|
15
|
+
orchestrator: input.orchestrator === true,
|
|
16
|
+
sessions: input.sessions.map(s => ({ id: s.id, automatic: s.automatic, ...(s.available !== undefined ? { available: s.available } : {}),
|
|
17
|
+
...(s.reason ? { reason: s.reason } : {}), ...(s.discoveredBy ? { discoveredBy: s.discoveredBy } : {}) })),
|
|
18
|
+
leftOut: (input.leftOut ?? []).map(l => ({ id: l.id, reason: l.reason })),
|
|
19
|
+
usageAgeSec: Object.fromEntries((input.usage ?? []).map(u => [u.provider, u.ageSec])),
|
|
20
|
+
usageState: Object.fromEntries((input.usage ?? []).map(u => [u.provider, u.state])),
|
|
21
|
+
assignments: (answer?.assignments ?? []).map(a => ({ mode: a.mode ?? 'continue', target: a.mode === 'newSession' ? `new:${a.provider}` : a.sessionID ?? '' })),
|
|
22
|
+
outcome, ms,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const WORK_CHAT_DEPS = {run:runMeetingAskModel, admissions:maintenanceAdmissionsOpen, lease:()=>acquireMaintenanceWork('work_chat'),
|
|
27
|
+
planLog:(row: PlanLogRow)=>appendPlanLog(PLAN_LOG_PATH(), row), now:()=>Date.now(),
|
|
28
|
+
lensPlansControl:()=>defaultLensRoundStore().controlStatus()}
|
|
29
|
+
export function createWorkChatRouter(given: Partial<typeof WORK_CHAT_DEPS> = {}) {
|
|
30
|
+
// 6.83.0: a caller that passes only some deps (the tests pass run, admissions and lease) gets the rest by default.
|
|
31
|
+
const deps={...WORK_CHAT_DEPS,...given}
|
|
7
32
|
const router=Router(); let running=false
|
|
8
33
|
router.use('/work/chat', (_req,res,next)=>{res.set('Cache-Control','private, no-store');next()})
|
|
9
34
|
// 6.73.15: queueRounds means sessions may carry openWork, and the planner routes around sessions with earlier work open.
|
|
10
35
|
// references means the input may carry read-only reference sessions (never targets).
|
|
11
|
-
|
|
36
|
+
// 6.83.0: orchestrator 1 means a client may send the orchestrator input (POST /api/work/orchestrator/input) and get
|
|
37
|
+
// fork and new-session assignments back (docs/work-orchestrator.md).
|
|
38
|
+
// 6.83.0: lensPlans 1 means the lens may record a plan into the card's Work chat history and ask COS Control to send
|
|
39
|
+
// it (routes/work-lens-rounds.ts); lensPlansControl says whether a COS Control polled the lens inbox in the last 60 s.
|
|
40
|
+
router.get('/work/chat',(_req,res)=>res.json({protocolVersion:1,planner:'sonnet',planners:WORK_CHAT_PLANNERS,effort:WORK_CHAT_DEFAULT_EFFORT,efforts:WORK_CHAT_EFFORTS,automaticProviders:['claude','codex'],maxAssignments:WORK_CHAT_MAX_ASSIGNMENTS,contextItems:true,maxContextItems:WORK_CHAT_MAX_ITEMS,queueRounds:true,references:true,maxReferences:WORK_CHAT_MAX_REFERENCES,orchestrator:1,
|
|
41
|
+
lensPlans:1,lensPlansControl:deps.lensPlansControl()}))
|
|
12
42
|
router.post('/work/chat',async(req,res)=>{
|
|
13
|
-
let input
|
|
14
|
-
try {
|
|
43
|
+
let input: WorkChatInput
|
|
44
|
+
try {
|
|
45
|
+
const limit=req.body?.orchestrator===true ? WORK_CHAT_ORCHESTRATOR_BODY_BYTES : WORK_CHAT_BODY_BYTES
|
|
46
|
+
if (Buffer.byteLength(JSON.stringify(req.body))>limit) throw new Error(); input=parseWorkChatInput(req.body)
|
|
47
|
+
}
|
|
15
48
|
catch {return res.status(400).json({error:'invalid_work_chat'})}
|
|
16
49
|
if (running || !deps.admissions()) return res.status(409).json({error:'work_chat_busy'})
|
|
17
50
|
running=true
|
|
51
|
+
const started=deps.now()
|
|
18
52
|
let lease: ReturnType<typeof acquireMaintenanceWork> | undefined
|
|
53
|
+
let outcome: PlanLogRow['outcome']='unavailable', answer: WorkChatAnswer | null=null
|
|
19
54
|
const controller=new AbortController(); const abort=()=>controller.abort()
|
|
20
55
|
res.on('close',abort)
|
|
21
56
|
try {
|
|
22
57
|
lease=deps.lease()
|
|
23
58
|
// Planner and effort select how the model runs; they are not evidence, so they never reach the prompt.
|
|
24
59
|
const {planner='sonnet',effort=WORK_CHAT_DEFAULT_EFFORT,...prompt}=input
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
const
|
|
60
|
+
const orchestrator=input.orchestrator===true
|
|
61
|
+
const system=orchestrator ? WORK_CHAT_SYSTEM+'\n\n'+WORK_CHAT_ORCHESTRATOR_RULES : WORK_CHAT_SYSTEM
|
|
62
|
+
const out=await deps.run({engine:'claude',model:planner},JSON.stringify(prompt),system,controller.signal,undefined,{caller:'work_chat_'+input.mode},
|
|
63
|
+
{effort,maxOutputTokens:orchestrator ? WORK_CHAT_ORCHESTRATOR_OUTPUT_TOKENS : WORK_CHAT_OUTPUT_TOKENS,maxThinkingTokens:WORK_CHAT_THINKING_TOKENS[effort],deadlineMs:WORK_CHAT_DEADLINE_MS})
|
|
64
|
+
try { answer=parseWorkChatAnswer(out.text,input) } catch (e) { outcome='invalid_answer'; throw e }
|
|
65
|
+
outcome='planned'
|
|
28
66
|
if (!res.destroyed) return res.json({protocolVersion:1,...answer,model:out.model??`claude:${planner} (requested)`,effort})
|
|
29
|
-
|
|
30
|
-
|
|
67
|
+
outcome='aborted'
|
|
68
|
+
} catch {if (controller.signal.aborted && outcome==='unavailable') outcome='aborted'; if(!res.destroyed) return res.status(503).json({error:'work_chat_unavailable',message:'No assignment was dispatched. Your saved request can be retried.'})}
|
|
69
|
+
finally {
|
|
70
|
+
res.off('close',abort);lease?.release();running=false
|
|
71
|
+
// 6.83.0: every plan is logged, whatever its outcome (plan-log.ts); a log failure never changes the answer.
|
|
72
|
+
try { deps.planLog(planLogRow(input,outcome,answer,deps.now()-started)) } catch { /* observation only */ }
|
|
73
|
+
}
|
|
31
74
|
})
|
|
32
75
|
return router
|
|
33
76
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.83.0: lens plans in Work chat history, and "Send this plan" (docs/work-orchestrator.md, "Lens plans").
|
|
3
|
+
*
|
|
4
|
+
* Lens (over the network, token auth from the parent /api mount):
|
|
5
|
+
* POST /api/work/chat/lens-rounds record a plan the lens made (idempotent on clientRoundId)
|
|
6
|
+
* GET /api/work/chat/lens-rounds/:roundId the round's live state, never its prompts
|
|
7
|
+
* POST /api/work/chat/lens-rounds/:roundId/send ask COS Control to send it (two-step confirm, or card Auto-send)
|
|
8
|
+
* POST /api/work/chat/lens-rounds/:roundId/stop ask COS Control to Stop assignments
|
|
9
|
+
* COS Control (this Mac only):
|
|
10
|
+
* GET /api/work/chat/lens-inbox?since=<cursor>[&autoSend=1] rounds not yet in history, send and stop requests
|
|
11
|
+
* POST /api/work/chat/lens-rounds/:roundId/history { state: "in_history", autoSendCard? }
|
|
12
|
+
* POST /api/work/chat/lens-send/:sendRequestId/result { assignments: [{ id, state, receiptId?, detail?, sessionID? }] }
|
|
13
|
+
* POST /api/work/chat/lens-rounds/:roundId/mac-send the same shape, for COS Control's own Send of this round on the Mac
|
|
14
|
+
*
|
|
15
|
+
* Nothing here sends to a session: COS Control performs every send through its own Send path and reports back.
|
|
16
|
+
* Every route answers 503 `maintenance` while admissions are closed; writes hold the api_mutation lease themselves
|
|
17
|
+
* (index.ts leaves these paths to this router so the error code is the contract's).
|
|
18
|
+
*/
|
|
19
|
+
import { Router, type Request, type Response } from 'express'
|
|
20
|
+
import { LensRoundError, LensRoundStore, LENS_ROUND_BODY_BYTES, ROUND_ID, SEND_ID, cardSessionsOf, parseLensRound, parseResultBody,
|
|
21
|
+
parseSendBody, parseStopBody, roundView, defaultLensRoundStore } from '../lib/work-lens-rounds.js'
|
|
22
|
+
import type { ControlSnapshot } from '../lib/work-orchestrator/snapshot.js'
|
|
23
|
+
import { orchestratorSnapshots } from '../lib/work-orchestrator/runtime.js'
|
|
24
|
+
import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
|
|
25
|
+
import { isLoopbackRequest } from './work-handoff-requests.js'
|
|
26
|
+
import { listBoard, type TaskBoardRow } from '../lib/task-store.js'
|
|
27
|
+
import { createWorkBoardReader, type WorkBoardReader } from '../lib/work-board-reader.js'
|
|
28
|
+
|
|
29
|
+
export interface WorkLensRoundsDeps {
|
|
30
|
+
store: LensRoundStore
|
|
31
|
+
/** COS Control's Work snapshot (links and receipts), for the card's sessions. */
|
|
32
|
+
snapshot: () => ControlSnapshot | null
|
|
33
|
+
/** Whether the workID is a card the server knows: in COS Control's snapshot or on the Work board. Throws when unknowable. */
|
|
34
|
+
knownWork: (workID: string) => Promise<boolean>
|
|
35
|
+
admissions: () => boolean
|
|
36
|
+
lease: () => { release(): void }
|
|
37
|
+
isLocal: (req: Request) => boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A card is known when COS Control's snapshot names it, or the Work board (a bounded read, 2 s old at most) lists it. */
|
|
41
|
+
export function knownWorkFrom(snapshot: () => ControlSnapshot | null, board: WorkBoardReader<TaskBoardRow>) {
|
|
42
|
+
return async (workID: string): Promise<boolean> => {
|
|
43
|
+
const s = snapshot()
|
|
44
|
+
if (s && (s.links.some(l => l.workID === workID) || s.receipts.some(r => r.workID === workID))) return true
|
|
45
|
+
const rows = await board.read(2_000)
|
|
46
|
+
return rows.some(r => `task:${r.domain}:${r.workIdentity || r.id}` === workID)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let defaultBoard: WorkBoardReader<TaskBoardRow> | null = null
|
|
51
|
+
const DEFAULTS = (): WorkLensRoundsDeps => ({
|
|
52
|
+
store: defaultLensRoundStore(),
|
|
53
|
+
snapshot: () => orchestratorSnapshots().get(),
|
|
54
|
+
knownWork: knownWorkFrom(() => orchestratorSnapshots().get(), defaultBoard ??= createWorkBoardReader(() => listBoard())),
|
|
55
|
+
admissions: maintenanceAdmissionsOpen,
|
|
56
|
+
lease: () => acquireMaintenanceWork('api_mutation'),
|
|
57
|
+
isLocal: isLoopbackRequest,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const MAINTENANCE = { error: 'maintenance', message: 'The server is updating. The plan is kept on the lens; try again shortly.' }
|
|
61
|
+
|
|
62
|
+
export function createWorkLensRoundsRouter(given: Partial<WorkLensRoundsDeps> = {}) {
|
|
63
|
+
const deps: WorkLensRoundsDeps = { ...DEFAULTS(), ...given }
|
|
64
|
+
const router = Router()
|
|
65
|
+
router.use(['/work/chat/lens-rounds', '/work/chat/lens-inbox', '/work/chat/lens-send'], (_req, res, next) => {
|
|
66
|
+
res.set('Cache-Control', 'private, no-store')
|
|
67
|
+
if (!deps.admissions()) return res.status(503).json(MAINTENANCE)
|
|
68
|
+
next()
|
|
69
|
+
})
|
|
70
|
+
const fail = (res: Response, e: unknown, where: string) => {
|
|
71
|
+
if (e instanceof LensRoundError) {
|
|
72
|
+
if (e.status >= 500 || where !== 'record') console.warn(`[work-lens-rounds] ${where}: ${e.status} ${e.code}`)
|
|
73
|
+
return res.status(e.status).json({ error: e.code, message: e.message, ...e.extra })
|
|
74
|
+
}
|
|
75
|
+
console.error(`[work-lens-rounds] ${where} failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
76
|
+
return res.status(503).json({ error: 'lens_rounds_unavailable', message: 'Lens plans are unavailable. Try again shortly.' })
|
|
77
|
+
}
|
|
78
|
+
/** A write holds the api_mutation lease for its own (sub-millisecond) body; a closed gate is `maintenance`. */
|
|
79
|
+
const write = <T>(res: Response, where: string, fn: () => Promise<T> | T) => {
|
|
80
|
+
let lease: { release(): void }
|
|
81
|
+
try { lease = deps.lease() } catch { return res.status(503).json(MAINTENANCE) }
|
|
82
|
+
return Promise.resolve().then(fn).catch(e => fail(res, e, where)).finally(() => lease.release())
|
|
83
|
+
}
|
|
84
|
+
const local = (req: Request) => { if (!deps.isLocal(req)) throw new LensRoundError('local_only', 403, 'Only COS Control on this Mac can do this.') }
|
|
85
|
+
const roundIdOf = (req: Request) => {
|
|
86
|
+
const id = String(req.params.roundId ?? '')
|
|
87
|
+
if (!ROUND_ID.test(id)) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
|
|
88
|
+
return id
|
|
89
|
+
}
|
|
90
|
+
const connected = () => deps.store.controlStatus().connected
|
|
91
|
+
|
|
92
|
+
router.post('/work/chat/lens-rounds', (req, res) => write(res, 'record', async () => {
|
|
93
|
+
if (Buffer.byteLength(JSON.stringify(req.body ?? null)) > LENS_ROUND_BODY_BYTES) {
|
|
94
|
+
return res.status(413).json({ error: 'lens_round_too_large', message: 'This plan is too large to save.' })
|
|
95
|
+
}
|
|
96
|
+
const input = parseLensRound(req.body)
|
|
97
|
+
// A repeat is answered from the store, even when the board has since changed or is unreachable.
|
|
98
|
+
const replay = deps.store.replay(input.clientRoundId)
|
|
99
|
+
if (!replay) {
|
|
100
|
+
let known: boolean
|
|
101
|
+
try { known = await deps.knownWork(input.workID) }
|
|
102
|
+
catch { throw new LensRoundError('work_board_unavailable', 503, 'The Work board is unavailable. The plan is kept on the lens; try again shortly.') }
|
|
103
|
+
if (!known) throw new LensRoundError('unknown_work', 404, 'This card is not on the Work board any more.')
|
|
104
|
+
}
|
|
105
|
+
const { round, created } = deps.store.record(input, cardSessionsOf(deps.snapshot(), input.workID))
|
|
106
|
+
if (created) console.log(`[work-lens-rounds] recorded ${round.roundId} work=${round.workID} assignments=${round.assignments.length}`)
|
|
107
|
+
return res.status(created ? 201 : 200).json({ roundId: round.roundId, workID: round.workID, state: roundView(round).state, control: { connected: connected() } })
|
|
108
|
+
}))
|
|
109
|
+
|
|
110
|
+
router.get('/work/chat/lens-rounds/:roundId', (req, res) => {
|
|
111
|
+
try {
|
|
112
|
+
const round = deps.store.find(roundIdOf(req))
|
|
113
|
+
if (!round) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
|
|
114
|
+
return res.json(roundView(round))
|
|
115
|
+
} catch (e) { return fail(res, e, 'status') }
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
router.post('/work/chat/lens-rounds/:roundId/send', (req, res) => write(res, 'send', () => {
|
|
119
|
+
const roundId = roundIdOf(req)
|
|
120
|
+
const body = parseSendBody(req.body)
|
|
121
|
+
const round = deps.store.find(roundId)
|
|
122
|
+
if (!round) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
|
|
123
|
+
const send = deps.store.requestSend(roundId, body, { cardSessions: cardSessionsOf(deps.snapshot(), round.workID), controlConnected: connected() })
|
|
124
|
+
console.log(`[work-lens-rounds] send requested ${send.id} round=${roundId} assignments=${send.assignmentIds.join(',')} confirm=${send.confirm ?? 'auto-send'}`)
|
|
125
|
+
return res.status(202).json({ sendRequestId: send.id, state: 'send_requested' })
|
|
126
|
+
}))
|
|
127
|
+
|
|
128
|
+
router.post('/work/chat/lens-rounds/:roundId/stop', (req, res) => write(res, 'stop', () => {
|
|
129
|
+
const roundId = roundIdOf(req)
|
|
130
|
+
const stop = deps.store.requestStop(roundId, parseStopBody(req.body), connected())
|
|
131
|
+
console.log(`[work-lens-rounds] stop requested ${stop.id} round=${roundId} assignments=${stop.assignmentIds.join(',')}`)
|
|
132
|
+
return res.status(202).json({ stopRequestId: stop.id, state: 'stop_requested' })
|
|
133
|
+
}))
|
|
134
|
+
|
|
135
|
+
// COS Control: polling this is what the capability reports as connected. Listing a send or a stop picks it up.
|
|
136
|
+
router.get('/work/chat/lens-inbox', (req, res) => {
|
|
137
|
+
try {
|
|
138
|
+
local(req)
|
|
139
|
+
const { since, autoSend } = req.query
|
|
140
|
+
if (Object.keys(req.query).some(k => k !== 'since' && k !== 'autoSend')
|
|
141
|
+
|| (since !== undefined && !(typeof since === 'string' && /^\d{1,12}$/.test(since)))
|
|
142
|
+
|| (autoSend !== undefined && autoSend !== '0' && autoSend !== '1')) {
|
|
143
|
+
throw new LensRoundError('invalid_lens_inbox', 400, 'since is a cursor and autoSend is 0 or 1.')
|
|
144
|
+
}
|
|
145
|
+
return res.json(deps.store.inbox(since === undefined ? 0 : Number(since), autoSend === '1'))
|
|
146
|
+
} catch (e) { return fail(res, e, 'inbox') }
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
router.post('/work/chat/lens-rounds/:roundId/history', (req, res) => write(res, 'history', () => {
|
|
150
|
+
local(req)
|
|
151
|
+
const roundId = roundIdOf(req)
|
|
152
|
+
const b = req.body
|
|
153
|
+
if (!b || typeof b !== 'object' || Array.isArray(b) || b.state !== 'in_history' || (b.autoSendCard !== undefined && typeof b.autoSendCard !== 'boolean')) {
|
|
154
|
+
throw new LensRoundError('invalid_lens_round', 400, 'History takes { state: "in_history" }.', { field: 'state' })
|
|
155
|
+
}
|
|
156
|
+
const round = deps.store.markInHistory(roundId, b.autoSendCard)
|
|
157
|
+
return res.json(roundView(round))
|
|
158
|
+
}))
|
|
159
|
+
|
|
160
|
+
// COS Control: it sent assignments of this round with its own Send on the Mac. Same assignments[] shape as a result.
|
|
161
|
+
router.post('/work/chat/lens-rounds/:roundId/mac-send', (req, res) => write(res, 'mac-send', () => {
|
|
162
|
+
local(req)
|
|
163
|
+
const round = deps.store.reportMac(roundIdOf(req), parseResultBody(req.body))
|
|
164
|
+
const view = roundView(round)
|
|
165
|
+
console.log(`[work-lens-rounds] mac send round=${round.roundId} state=${view.state} ${view.assignments.map(a => `${a.id}:${a.state}`).join(' ')}`)
|
|
166
|
+
return res.json(view)
|
|
167
|
+
}))
|
|
168
|
+
|
|
169
|
+
router.post('/work/chat/lens-send/:sendRequestId/result', (req, res) => write(res, 'result', () => {
|
|
170
|
+
local(req)
|
|
171
|
+
const id = String(req.params.sendRequestId ?? '')
|
|
172
|
+
if (!SEND_ID.test(id)) throw new LensRoundError('unknown_send', 404, 'No such send request.')
|
|
173
|
+
const round = deps.store.report(id, parseResultBody(req.body))
|
|
174
|
+
const view = roundView(round)
|
|
175
|
+
console.log(`[work-lens-rounds] result ${id} round=${round.roundId} state=${view.state} ${view.assignments.map(a => `${a.id}:${a.state}`).join(' ')}`)
|
|
176
|
+
return res.json(view)
|
|
177
|
+
}))
|
|
178
|
+
return router
|
|
179
|
+
}
|