@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,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.83.0: lens plans in Work chat history, and "Send this plan" (contract:
|
|
3
|
+
* operations/personal/wk43_2026/CONTRACT_lens_plans_6.83.0_2026-10-21.md in the MU repo; docs/work-orchestrator.md).
|
|
4
|
+
*
|
|
5
|
+
* The lens records a plan it made (POST /api/work/chat with the orchestrator input) as a lens round, and may ask for it
|
|
6
|
+
* to be sent. This store only holds rounds and requests. COS Control owns every send and its Work chat journal: it pulls
|
|
7
|
+
* the inbox, writes the round into the card's Work chat record, performs each send through its own Send path and
|
|
8
|
+
* reports results. Nothing here sends to a session.
|
|
9
|
+
*
|
|
10
|
+
* Storage: `data/work-lens-rounds.json`, atomic (0600), at most 200 rounds, each kept 14 days. Ids, states, times and
|
|
11
|
+
* short text only; plans carry prompts, which are the user's own words.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, renameSync } from 'node:fs'
|
|
14
|
+
import { dirname } from 'node:path'
|
|
15
|
+
import { randomBytes } from 'node:crypto'
|
|
16
|
+
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
17
|
+
import { dataPath } from './data-dir.js'
|
|
18
|
+
import { WORK_CHAT_EFFORTS, WORK_CHAT_MAX_ASSIGNMENTS, WORK_CHAT_MAX_ITEMS, WORK_CHAT_PLANNERS } from './work-chat.js'
|
|
19
|
+
import type { ControlSnapshot } from './work-orchestrator/snapshot.js'
|
|
20
|
+
|
|
21
|
+
export const LENS_ROUNDS_MAX = 200
|
|
22
|
+
export const LENS_ROUND_TTL_MS = 14 * 24 * 3600_000
|
|
23
|
+
/** A send request COS Control has not picked up in 10 minutes is expired. */
|
|
24
|
+
export const LENS_SEND_PICKUP_MS = 10 * 60_000
|
|
25
|
+
/** A picked-up send whose assignment COS Control never reported in 15 minutes is marked failed, so the lens stops polling. */
|
|
26
|
+
export const LENS_SEND_REPORT_MS = 15 * 60_000
|
|
27
|
+
/** COS Control is connected while it has polled the inbox in the last 60 seconds. */
|
|
28
|
+
export const LENS_CONTROL_FRESH_MS = 60_000
|
|
29
|
+
export const LENS_INBOX_MAX = 50
|
|
30
|
+
export const LENS_ROUND_BODY_BYTES = 256_000
|
|
31
|
+
export const LENS_QUESTION_CHARS = 4000
|
|
32
|
+
export const LENS_ANSWER_CHARS = 6000
|
|
33
|
+
export const LENS_PROMPT_CHARS = 6000
|
|
34
|
+
export const LENS_DETAIL_CHARS = 200
|
|
35
|
+
const MAX_SENDS_KEPT = 8
|
|
36
|
+
const MAX_STOPS_KEPT = 8
|
|
37
|
+
|
|
38
|
+
const WORK_ID = /^task:[a-z_]+:[a-f0-9]{12}$/
|
|
39
|
+
const CLIENT_ROUND_ID = /^lens-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
40
|
+
const SID = /^(claude|codex|cursor):[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
|
|
41
|
+
const ITEM_ID = /^[a-z]{1,8}(:[a-f0-9]{12})?$/
|
|
42
|
+
const RECEIPT_ID = /^[A-Za-z0-9_.:-]{1,80}$/
|
|
43
|
+
export const ROUND_ID = /^lr_[a-f0-9]{16}$/
|
|
44
|
+
export const SEND_ID = /^ls_[a-f0-9]{16}$/
|
|
45
|
+
|
|
46
|
+
export const ASSIGNMENT_MODES = ['continue', 'fork', 'newSession'] as const
|
|
47
|
+
export const ASSIGNMENT_STATES = ['pending', 'held', 'sent', 'delivered', 'failed', 'refused', 'stopped'] as const
|
|
48
|
+
export const ROUND_STATES = ['recorded', 'in_history', 'send_requested', 'sending', 'sent', 'partly_sent', 'failed', 'stopped', 'refused', 'expired'] as const
|
|
49
|
+
export type AssignmentMode = typeof ASSIGNMENT_MODES[number]
|
|
50
|
+
export type AssignmentState = typeof ASSIGNMENT_STATES[number]
|
|
51
|
+
export type RoundState = typeof ROUND_STATES[number]
|
|
52
|
+
type SendState = 'send_requested' | 'sending' | Outcome | 'expired'
|
|
53
|
+
type Outcome = 'sent' | 'partly_sent' | 'failed' | 'stopped' | 'refused'
|
|
54
|
+
const TERMINAL: readonly AssignmentState[] = ['sent', 'delivered', 'failed', 'refused', 'stopped']
|
|
55
|
+
const SENT_LIKE: readonly AssignmentState[] = ['sent', 'delivered']
|
|
56
|
+
/** An assignment the lens may not send again: it reached its session, or COS Control holds it to send after earlier work. */
|
|
57
|
+
const ALREADY: readonly AssignmentState[] = ['sent', 'delivered', 'held']
|
|
58
|
+
|
|
59
|
+
export interface LensAssignment {
|
|
60
|
+
id: string; mode: AssignmentMode; sessionID?: string; provider?: string; prompt: string; contextItemIDs?: string[]
|
|
61
|
+
state: AssignmentState; receiptId?: string; detail?: string; reportedSessionID?: string; updatedAt?: string
|
|
62
|
+
}
|
|
63
|
+
export interface LensSendRequest {
|
|
64
|
+
id: string; assignmentIds: string[]; confirm: 'two-step' | null; confirmedAt: string | null; autoSend: boolean
|
|
65
|
+
requestedAt: number; pickedUpAt: number | null; state: SendState; finishedAt: number | null
|
|
66
|
+
}
|
|
67
|
+
export interface LensStopRequest { id: string; assignmentIds: string[]; sendRequestId: string | null; requestedAt: number; pickedUpAt: number | null }
|
|
68
|
+
export interface LensRound {
|
|
69
|
+
roundId: string; seq: number; clientRoundId: string; workID: string; question: string; answer: string
|
|
70
|
+
planner: string | null; effort: string | null; orchestrator: boolean; assignments: LensAssignment[]
|
|
71
|
+
createdAt: string; recordedAt: number; inHistory: boolean; historyAt: number | null; autoSendCard: boolean
|
|
72
|
+
/** The card's sessions (Control snapshot links and receipts) when the plan was recorded; null when no snapshot. */
|
|
73
|
+
cardSessions: string[] | null
|
|
74
|
+
sends: LensSendRequest[]; stops: LensStopRequest[]; updatedAt: number
|
|
75
|
+
/** COS Control's own Send of this round's assignments on the Mac (the lens was told to send from the Mac): when, and which. */
|
|
76
|
+
mac?: { at: number; ids: string[] } | null
|
|
77
|
+
}
|
|
78
|
+
interface StoreFile { version: 1; seq: number; rounds: LensRound[] }
|
|
79
|
+
|
|
80
|
+
export class LensRoundError extends Error {
|
|
81
|
+
constructor(readonly code: string, readonly status: number, message: string, readonly extra: Record<string, unknown> = {}) { super(message) }
|
|
82
|
+
}
|
|
83
|
+
const invalid = (field: string, message = `The plan's ${field} is not valid.`) => new LensRoundError('invalid_lens_round', 400, message, { field })
|
|
84
|
+
|
|
85
|
+
const obj = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
86
|
+
const str = (v: unknown, n: number): v is string => typeof v === 'string' && v.length <= n && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(v)
|
|
87
|
+
const iso = (v: unknown): v is string => str(v, 40) && !Number.isNaN(Date.parse(v as string))
|
|
88
|
+
const newId = (prefix: string) => `${prefix}_${randomBytes(8).toString('hex')}`
|
|
89
|
+
|
|
90
|
+
export interface LensRoundInput {
|
|
91
|
+
workID: string; clientRoundId: string; question: string; answer: string; planner: string | null; effort: string | null
|
|
92
|
+
orchestrator: boolean; assignments: Omit<LensAssignment, 'state'>[]; createdAt: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The record body. Unknown keys are ignored and never stored. */
|
|
96
|
+
export function parseLensRound(v: unknown): LensRoundInput {
|
|
97
|
+
if (!obj(v)) throw invalid('body', 'The plan could not be read.')
|
|
98
|
+
if (v.v !== 1) throw invalid('v')
|
|
99
|
+
if (!str(v.workID, 256) || !WORK_ID.test(v.workID)) throw invalid('workID')
|
|
100
|
+
if (!str(v.clientRoundId, 60) || !CLIENT_ROUND_ID.test(v.clientRoundId)) throw invalid('clientRoundId')
|
|
101
|
+
if (!str(v.question, LENS_QUESTION_CHARS) || !v.question.trim()) throw invalid('question')
|
|
102
|
+
if (!str(v.answer, LENS_ANSWER_CHARS)) throw invalid('answer')
|
|
103
|
+
if (v.planner !== undefined && !(WORK_CHAT_PLANNERS as readonly unknown[]).includes(v.planner)) throw invalid('planner')
|
|
104
|
+
if (v.effort !== undefined && !(WORK_CHAT_EFFORTS as readonly unknown[]).includes(v.effort)) throw invalid('effort')
|
|
105
|
+
if (v.orchestrator !== undefined && typeof v.orchestrator !== 'boolean') throw invalid('orchestrator')
|
|
106
|
+
if (v.source !== 'lens') throw invalid('source')
|
|
107
|
+
if (!iso(v.createdAt)) throw invalid('createdAt')
|
|
108
|
+
if (!Array.isArray(v.assignments) || v.assignments.length > WORK_CHAT_MAX_ASSIGNMENTS) throw invalid('assignments')
|
|
109
|
+
const assignments: LensRoundInput['assignments'] = []
|
|
110
|
+
v.assignments.forEach((a: unknown, i: number) => {
|
|
111
|
+
if (!obj(a) || a.id !== `a${i + 1}`) throw invalid('assignments.id', 'Assignment ids must be a1 to aN in answer order.')
|
|
112
|
+
const mode = a.mode ?? 'continue'
|
|
113
|
+
if (!(ASSIGNMENT_MODES as readonly unknown[]).includes(mode)) throw invalid('assignments.mode')
|
|
114
|
+
if (mode !== 'newSession' && !(str(a.sessionID, 64) && SID.test(a.sessionID))) throw invalid('assignments.sessionID')
|
|
115
|
+
if (mode === 'newSession' && a.sessionID !== undefined && a.sessionID !== null) throw invalid('assignments.sessionID')
|
|
116
|
+
if (mode === 'newSession' ? !['claude', 'codex'].includes(a.provider) : (a.provider !== undefined && !['claude', 'codex', 'cursor'].includes(a.provider))) throw invalid('assignments.provider')
|
|
117
|
+
if (!str(a.prompt, LENS_PROMPT_CHARS) || !a.prompt.trim()) throw invalid('assignments.prompt')
|
|
118
|
+
if (a.contextItemIDs !== undefined && !(Array.isArray(a.contextItemIDs) && a.contextItemIDs.length <= WORK_CHAT_MAX_ITEMS
|
|
119
|
+
&& a.contextItemIDs.every((id: unknown) => str(id, 24) && ITEM_ID.test(id)))) throw invalid('assignments.contextItemIDs')
|
|
120
|
+
assignments.push({ id: a.id, mode: mode as AssignmentMode, ...(mode !== 'newSession' ? { sessionID: a.sessionID } : {}),
|
|
121
|
+
...(a.provider ? { provider: a.provider } : {}), prompt: a.prompt,
|
|
122
|
+
...(a.contextItemIDs ? { contextItemIDs: [...new Set<string>(a.contextItemIDs)] } : {}) })
|
|
123
|
+
})
|
|
124
|
+
return { workID: v.workID, clientRoundId: v.clientRoundId, question: v.question, answer: v.answer, planner: v.planner ?? null,
|
|
125
|
+
effort: v.effort ?? null, orchestrator: v.orchestrator === true, assignments, createdAt: v.createdAt }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The card's sessions as COS Control's snapshot records them: confirmed links, receipt targets and fork children. */
|
|
129
|
+
export function cardSessionsOf(snapshot: ControlSnapshot | null, workID: string): string[] | null {
|
|
130
|
+
if (!snapshot) return null
|
|
131
|
+
const out = new Set<string>()
|
|
132
|
+
for (const l of snapshot.links) if (l.workID === workID) out.add(l.sessionID)
|
|
133
|
+
for (const r of snapshot.receipts) {
|
|
134
|
+
if (r.workID !== workID) continue
|
|
135
|
+
if (r.sessionID && !(r.mode === 'fork' && r.sessionID === r.sourceSessionID)) out.add(r.sessionID)
|
|
136
|
+
if (r.forkChildID) out.add(r.forkChildID)
|
|
137
|
+
}
|
|
138
|
+
return [...out].sort()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A plan is stale when the card's sessions changed since it was recorded: a session added (a link, a receipt's new
|
|
143
|
+
* session, a fork child) or one gone. Sessions this round's own sends reported (a New session, a fork child) are not
|
|
144
|
+
* a change, so a partly sent plan can send the rest. Unknown on either side (no snapshot) is never stale.
|
|
145
|
+
*/
|
|
146
|
+
export function planIsStale(round: LensRound, now: string[] | null): boolean {
|
|
147
|
+
if (!round.cardSessions || !now) return false
|
|
148
|
+
const before = new Set(round.cardSessions)
|
|
149
|
+
const own = new Set(round.assignments.map(a => a.reportedSessionID).filter((s): s is string => !!s && !before.has(s)))
|
|
150
|
+
const after = now.filter(s => !own.has(s))
|
|
151
|
+
return after.length !== before.size || after.some(s => !before.has(s))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Where a send ended, from its assignments (all final). All reached their sessions: `sent`; some: `partly_sent`. None:
|
|
156
|
+
* `stopped` when every one was stopped, `refused` when every one not stopped was refused (confirmation, a stale plan, a
|
|
157
|
+
* COS Control check), otherwise `failed`.
|
|
158
|
+
*/
|
|
159
|
+
export function sendOutcome(states: AssignmentState[]): Outcome {
|
|
160
|
+
const sent = states.filter(s => SENT_LIKE.includes(s)).length
|
|
161
|
+
if (sent === states.length) return 'sent'
|
|
162
|
+
if (sent > 0) return 'partly_sent'
|
|
163
|
+
if (states.every(s => s === 'stopped')) return 'stopped'
|
|
164
|
+
return states.every(s => s === 'refused' || s === 'stopped') ? 'refused' : 'failed'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface ControlSeen { at: number; autoSend: boolean }
|
|
168
|
+
|
|
169
|
+
export class LensRoundStore {
|
|
170
|
+
private file: StoreFile = { version: 1, seq: 0, rounds: [] }
|
|
171
|
+
private control: ControlSeen | null = null
|
|
172
|
+
constructor(private readonly path: string | null, private readonly now: () => number = Date.now) {
|
|
173
|
+
if (!path || !existsSync(path)) return
|
|
174
|
+
try {
|
|
175
|
+
const v = JSON.parse(readFileSync(path, 'utf8'))
|
|
176
|
+
if (!obj(v) || v.version !== 1 || !Array.isArray(v.rounds) || typeof v.seq !== 'number') throw new Error('unrecognised')
|
|
177
|
+
this.file = { version: 1, seq: v.seq, rounds: v.rounds.filter((r: unknown) => obj(r) && typeof r.roundId === 'string' && ROUND_ID.test(r.roundId)).slice(-LENS_ROUNDS_MAX) }
|
|
178
|
+
} catch (e) {
|
|
179
|
+
// Never overwrite what could not be read: move it aside and start empty.
|
|
180
|
+
const aside = `${path}.unreadable-${this.now()}`
|
|
181
|
+
try { renameSync(path, aside) } catch { /* nothing to move */ }
|
|
182
|
+
console.warn(`[work-lens-rounds] ${path} could not be read (${(e as Error).message}); moved to ${aside}`)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private persist(): void {
|
|
187
|
+
if (!this.path) return
|
|
188
|
+
try { mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 }); atomicWriteFileSync(this.path, JSON.stringify(this.file)) }
|
|
189
|
+
catch (e) { console.warn(`[work-lens-rounds] kept in memory only: ${(e as Error).message}`) }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Drops rounds past 14 days, expires sends not picked up in 10 minutes, and fails sends never reported. */
|
|
193
|
+
private sweep(): boolean {
|
|
194
|
+
const now = this.now()
|
|
195
|
+
let changed = false
|
|
196
|
+
const kept = this.file.rounds.filter(r => now - r.recordedAt <= LENS_ROUND_TTL_MS)
|
|
197
|
+
if (kept.length !== this.file.rounds.length) { this.file.rounds = kept; changed = true }
|
|
198
|
+
for (const r of this.file.rounds) {
|
|
199
|
+
const send = r.sends[r.sends.length - 1]
|
|
200
|
+
if (!send) continue
|
|
201
|
+
if (send.state === 'send_requested' && now - send.requestedAt > LENS_SEND_PICKUP_MS) {
|
|
202
|
+
send.state = 'expired'; send.finishedAt = now; r.updatedAt = now; changed = true
|
|
203
|
+
} else if (send.state === 'sending' && send.pickedUpAt !== null && now - send.pickedUpAt > LENS_SEND_REPORT_MS) {
|
|
204
|
+
const mine = r.assignments.filter(a => send.assignmentIds.includes(a.id))
|
|
205
|
+
const silent = mine.filter(a => a.state === 'pending')
|
|
206
|
+
if (silent.length) {
|
|
207
|
+
for (const a of silent) { a.state = 'failed'; a.detail = 'COS Control did not report this send.'; a.updatedAt = new Date(now).toISOString() }
|
|
208
|
+
this.settle(r, send, now); changed = true
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return changed
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private settle(r: LensRound, send: LensSendRequest, now: number): void {
|
|
216
|
+
const states = r.assignments.filter(a => send.assignmentIds.includes(a.id)).map(a => a.state)
|
|
217
|
+
if (states.every(s => TERMINAL.includes(s))) { send.state = sendOutcome(states); send.finishedAt ??= now }
|
|
218
|
+
else if (send.state !== 'sending') { send.state = 'sending'; send.finishedAt = null }
|
|
219
|
+
r.updatedAt = now
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private tick(): void { if (this.sweep()) this.persist() }
|
|
223
|
+
|
|
224
|
+
find(roundId: string): LensRound | undefined { this.tick(); return this.file.rounds.find(r => r.roundId === roundId) }
|
|
225
|
+
|
|
226
|
+
controlStatus(): { connected: boolean; lastSeenSec: number | null; autoSend: boolean } {
|
|
227
|
+
const c = this.control
|
|
228
|
+
if (!c) return { connected: false, lastSeenSec: null, autoSend: false }
|
|
229
|
+
const age = Math.max(0, this.now() - c.at)
|
|
230
|
+
return { connected: age <= LENS_CONTROL_FRESH_MS, lastSeenSec: Math.round(age / 1000), autoSend: c.autoSend }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
record(input: LensRoundInput, cardSessions: string[] | null): { round: LensRound; created: boolean } {
|
|
234
|
+
this.tick()
|
|
235
|
+
const existing = this.replay(input.clientRoundId)
|
|
236
|
+
if (existing) return { round: existing, created: false }
|
|
237
|
+
const now = this.now()
|
|
238
|
+
const round: LensRound = {
|
|
239
|
+
roundId: newId('lr'), seq: ++this.file.seq, clientRoundId: input.clientRoundId, workID: input.workID, question: input.question,
|
|
240
|
+
answer: input.answer, planner: input.planner, effort: input.effort, orchestrator: input.orchestrator,
|
|
241
|
+
assignments: input.assignments.map(a => ({ ...a, state: 'pending' as const })), createdAt: input.createdAt, recordedAt: now,
|
|
242
|
+
inHistory: false, historyAt: null, autoSendCard: false, cardSessions, sends: [], stops: [], updatedAt: now,
|
|
243
|
+
}
|
|
244
|
+
this.file.rounds.push(round)
|
|
245
|
+
while (this.file.rounds.length > LENS_ROUNDS_MAX) {
|
|
246
|
+
const idle = this.file.rounds.findIndex(r => !r.sends.some(s => s.state === 'send_requested' || s.state === 'sending'))
|
|
247
|
+
this.file.rounds.splice(idle >= 0 ? idle : 0, 1)
|
|
248
|
+
}
|
|
249
|
+
this.persist()
|
|
250
|
+
return { round, created: true }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
replay(clientRoundId: string): LensRound | undefined { return this.file.rounds.find(r => r.clientRoundId === clientRoundId) }
|
|
254
|
+
|
|
255
|
+
private active(r: LensRound): LensSendRequest | undefined {
|
|
256
|
+
const s = r.sends[r.sends.length - 1]
|
|
257
|
+
return s && (s.state === 'send_requested' || s.state === 'sending') ? s : undefined
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A send request. Omitted ids mean the whole plan, minus assignments already sent (so a partly sent plan sends the
|
|
262
|
+
* rest); naming an assignment already sent, or nothing left to send, is `already_sent`.
|
|
263
|
+
*/
|
|
264
|
+
requestSend(roundId: string, body: { assignmentIds?: string[]; confirm: 'two-step' | null; confirmedAt: string | null },
|
|
265
|
+
checks: { cardSessions: string[] | null; controlConnected: boolean }): LensSendRequest {
|
|
266
|
+
const r = this.find(roundId)
|
|
267
|
+
if (!r) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
|
|
268
|
+
if (this.active(r)) throw new LensRoundError('send_in_progress', 409, 'This plan is already being sent.')
|
|
269
|
+
const known = new Set(r.assignments.map(a => a.id))
|
|
270
|
+
if (body.assignmentIds && body.assignmentIds.some(id => !known.has(id))) throw invalid('assignmentIds', 'That assignment is not in this plan.')
|
|
271
|
+
const sentAlready = (id: string) => ALREADY.includes(r.assignments.find(a => a.id === id)!.state)
|
|
272
|
+
const ids = body.assignmentIds ? [...new Set(body.assignmentIds)] : r.assignments.map(a => a.id).filter(id => !sentAlready(id))
|
|
273
|
+
if (!ids.length || ids.some(sentAlready)) {
|
|
274
|
+
throw new LensRoundError('already_sent', 409, 'This plan was already sent.', { assignmentIds: r.assignments.map(a => a.id).filter(sentAlready) })
|
|
275
|
+
}
|
|
276
|
+
if (planIsStale(r, checks.cardSessions)) throw new LensRoundError('plan_stale', 409, "This card's sessions changed since the plan. Plan again.")
|
|
277
|
+
if (body.confirm !== 'two-step' && !r.autoSendCard) throw new LensRoundError('confirmation_required', 428, 'Confirm on the lens to send this plan.')
|
|
278
|
+
if (!checks.controlConnected) throw new LensRoundError('control_not_connected', 423, 'COS Control is not connected. The plan is saved; send it from the Mac.')
|
|
279
|
+
const now = this.now()
|
|
280
|
+
const send: LensSendRequest = { id: newId('ls'), assignmentIds: ids, confirm: body.confirm, confirmedAt: body.confirmedAt,
|
|
281
|
+
autoSend: body.confirm !== 'two-step', requestedAt: now, pickedUpAt: null, state: 'send_requested', finishedAt: null }
|
|
282
|
+
for (const a of r.assignments) if (ids.includes(a.id)) { a.state = 'pending'; delete a.detail; delete a.receiptId }
|
|
283
|
+
r.sends.push(send); if (r.sends.length > MAX_SENDS_KEPT) r.sends.splice(0, r.sends.length - MAX_SENDS_KEPT)
|
|
284
|
+
r.updatedAt = now
|
|
285
|
+
this.persist()
|
|
286
|
+
return send
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
requestStop(roundId: string, assignmentIds: string[], controlConnected: boolean): LensStopRequest {
|
|
290
|
+
const r = this.find(roundId)
|
|
291
|
+
if (!r) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
|
|
292
|
+
const known = new Set(r.assignments.map(a => a.id))
|
|
293
|
+
if (!assignmentIds.length || assignmentIds.some(id => !known.has(id))) throw invalid('assignmentIds', 'That assignment is not in this plan.')
|
|
294
|
+
if (!controlConnected) throw new LensRoundError('control_not_connected', 423, 'COS Control is not connected. Stop it from the Mac.')
|
|
295
|
+
const now = this.now()
|
|
296
|
+
const ids = [...new Set(assignmentIds)]
|
|
297
|
+
const send = [...r.sends].reverse().find(s => ids.some(id => s.assignmentIds.includes(id))) ?? null
|
|
298
|
+
const stop: LensStopRequest = { id: newId('lst'), assignmentIds: ids, sendRequestId: send?.id ?? null, requestedAt: now, pickedUpAt: null }
|
|
299
|
+
r.stops.push(stop); if (r.stops.length > MAX_STOPS_KEPT) r.stops.splice(0, r.stops.length - MAX_STOPS_KEPT)
|
|
300
|
+
r.updatedAt = now
|
|
301
|
+
this.persist()
|
|
302
|
+
return stop
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* COS Control's poll. Lists rounds not yet in history (after `since`, oldest first), send requests waiting to be picked
|
|
307
|
+
* up and stop requests, never more than 50 in all. Listing a send or a stop is its pick-up: it is listed once.
|
|
308
|
+
*/
|
|
309
|
+
inbox(since: number, autoSend: boolean) {
|
|
310
|
+
this.tick()
|
|
311
|
+
const now = this.now()
|
|
312
|
+
this.control = { at: now, autoSend }
|
|
313
|
+
const rounds: LensRound[] = [], sends: { round: LensRound; send: LensSendRequest }[] = [], stops: { round: LensRound; stop: LensStopRequest }[] = []
|
|
314
|
+
let room = LENS_INBOX_MAX, changed = false
|
|
315
|
+
for (const r of this.file.rounds) {
|
|
316
|
+
for (const s of r.sends) if (room > 0 && s.state === 'send_requested') { s.state = 'sending'; s.pickedUpAt = now; r.updatedAt = now; sends.push({ round: r, send: s }); room--; changed = true }
|
|
317
|
+
for (const s of r.stops) if (room > 0 && s.pickedUpAt === null) { s.pickedUpAt = now; stops.push({ round: r, stop: s }); room--; changed = true }
|
|
318
|
+
}
|
|
319
|
+
for (const r of this.file.rounds) if (room > 0 && !r.inHistory && r.seq > since) { rounds.push(r); room-- }
|
|
320
|
+
if (changed) this.persist()
|
|
321
|
+
const content = (r: LensRound) => ({ roundId: r.roundId, workID: r.workID, clientRoundId: r.clientRoundId, question: r.question, answer: r.answer,
|
|
322
|
+
planner: r.planner, effort: r.effort, orchestrator: r.orchestrator, createdAt: r.createdAt, recordedAt: new Date(r.recordedAt).toISOString(),
|
|
323
|
+
assignments: r.assignments.map(a => ({ id: a.id, mode: a.mode, ...(a.sessionID ? { sessionID: a.sessionID } : {}), ...(a.provider ? { provider: a.provider } : {}),
|
|
324
|
+
prompt: a.prompt, ...(a.contextItemIDs ? { contextItemIDs: a.contextItemIDs } : {}) })) })
|
|
325
|
+
return {
|
|
326
|
+
v: 1 as const,
|
|
327
|
+
cursor: rounds.reduce((m, r) => Math.max(m, r.seq), since),
|
|
328
|
+
rounds: rounds.map(content),
|
|
329
|
+
sends: sends.map(({ round, send }) => ({ sendRequestId: send.id, roundId: round.roundId, workID: round.workID, assignmentIds: send.assignmentIds,
|
|
330
|
+
confirm: send.confirm, confirmedAt: send.confirmedAt, autoSend: send.autoSend, requestedAt: new Date(send.requestedAt).toISOString(),
|
|
331
|
+
round: content(round) })),
|
|
332
|
+
stops: stops.map(({ round, stop }) => ({ stopRequestId: stop.id, roundId: round.roundId, workID: round.workID, assignmentIds: stop.assignmentIds,
|
|
333
|
+
sendRequestId: stop.sendRequestId, requestedAt: new Date(stop.requestedAt).toISOString() })),
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
markInHistory(roundId: string, autoSendCard: boolean | undefined): LensRound {
|
|
338
|
+
const r = this.find(roundId)
|
|
339
|
+
if (!r) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
|
|
340
|
+
const now = this.now()
|
|
341
|
+
if (!r.inHistory) { r.inHistory = true; r.historyAt = now }
|
|
342
|
+
if (autoSendCard !== undefined) r.autoSendCard = autoSendCard
|
|
343
|
+
r.updatedAt = now
|
|
344
|
+
this.persist()
|
|
345
|
+
return r
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* COS Control sent assignments of this round with its own Send on the Mac (no lens send request). Their states land on
|
|
350
|
+
* the round, so a later lens send never delivers them twice (`already_sent`) and an omitted list sends only the rest.
|
|
351
|
+
*/
|
|
352
|
+
reportMac(roundId: string, results: { id: string; state: AssignmentState; receiptId?: string; detail?: string; sessionID?: string }[]): LensRound {
|
|
353
|
+
const r = this.find(roundId)
|
|
354
|
+
if (!r) throw new LensRoundError('unknown_round', 404, 'This plan is not on the server any more.')
|
|
355
|
+
if (results.some(x => !r.assignments.some(a => a.id === x.id))) throw invalid('assignments.id', 'That assignment is not in this plan.')
|
|
356
|
+
const now = this.now(), at = new Date(now).toISOString()
|
|
357
|
+
for (const x of results) {
|
|
358
|
+
const a = r.assignments.find(y => y.id === x.id)!
|
|
359
|
+
a.state = x.state; a.updatedAt = at
|
|
360
|
+
if (x.receiptId) a.receiptId = x.receiptId
|
|
361
|
+
if (x.detail !== undefined) a.detail = x.detail
|
|
362
|
+
if (x.sessionID) a.reportedSessionID = x.sessionID
|
|
363
|
+
}
|
|
364
|
+
r.mac = { at: now, ids: [...new Set([...(r.mac?.ids ?? []), ...results.map(x => x.id)])] }
|
|
365
|
+
r.updatedAt = now
|
|
366
|
+
this.persist()
|
|
367
|
+
return r
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
report(sendRequestId: string, results: { id: string; state: AssignmentState; receiptId?: string; detail?: string; sessionID?: string }[]): LensRound {
|
|
371
|
+
this.tick()
|
|
372
|
+
const r = this.file.rounds.find(x => x.sends.some(s => s.id === sendRequestId))
|
|
373
|
+
const send = r?.sends.find(s => s.id === sendRequestId)
|
|
374
|
+
if (!r || !send) throw new LensRoundError('unknown_send', 404, 'No such send request.')
|
|
375
|
+
if (send.state === 'send_requested' || send.state === 'expired') throw new LensRoundError('send_not_picked_up', 409, 'This send request was not picked up.')
|
|
376
|
+
if (results.some(x => !send.assignmentIds.includes(x.id))) throw invalid('assignments.id', 'That assignment is not in this send request.')
|
|
377
|
+
const now = this.now(), at = new Date(now).toISOString()
|
|
378
|
+
for (const x of results) {
|
|
379
|
+
const a = r.assignments.find(y => y.id === x.id)!
|
|
380
|
+
a.state = x.state; a.updatedAt = at
|
|
381
|
+
if (x.receiptId) a.receiptId = x.receiptId
|
|
382
|
+
if (x.detail !== undefined) a.detail = x.detail
|
|
383
|
+
if (x.sessionID) a.reportedSessionID = x.sessionID
|
|
384
|
+
}
|
|
385
|
+
this.settle(r, send, now)
|
|
386
|
+
this.persist()
|
|
387
|
+
return r
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function parseSendBody(v: unknown): { assignmentIds?: string[]; confirm: 'two-step' | null; confirmedAt: string | null } {
|
|
392
|
+
if (v !== undefined && !obj(v)) throw invalid('body', 'The send request could not be read.')
|
|
393
|
+
const b = (v ?? {}) as Record<string, any>
|
|
394
|
+
if (b.v !== undefined && b.v !== 1) throw invalid('v')
|
|
395
|
+
if (b.assignmentIds !== undefined && !(Array.isArray(b.assignmentIds) && b.assignmentIds.length >= 1 && b.assignmentIds.length <= WORK_CHAT_MAX_ASSIGNMENTS
|
|
396
|
+
&& b.assignmentIds.every((id: unknown) => typeof id === 'string' && /^a[1-9]$/.test(id)))) throw invalid('assignmentIds')
|
|
397
|
+
if (b.confirm !== undefined && b.confirm !== 'two-step') throw invalid('confirm')
|
|
398
|
+
if (b.confirmedAt !== undefined && !iso(b.confirmedAt)) throw invalid('confirmedAt')
|
|
399
|
+
if (b.confirm === 'two-step' && b.confirmedAt === undefined) throw invalid('confirmedAt', 'A confirmed send carries the time it was confirmed.')
|
|
400
|
+
return { ...(b.assignmentIds ? { assignmentIds: b.assignmentIds } : {}), confirm: b.confirm ?? null, confirmedAt: b.confirmedAt ?? null }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function parseStopBody(v: unknown): string[] {
|
|
404
|
+
if (!obj(v) || (v.v !== undefined && v.v !== 1) || !Array.isArray(v.assignmentIds) || v.assignmentIds.length < 1 || v.assignmentIds.length > WORK_CHAT_MAX_ASSIGNMENTS
|
|
405
|
+
|| !v.assignmentIds.every((id: unknown) => typeof id === 'string' && /^a[1-9]$/.test(id))) throw invalid('assignmentIds')
|
|
406
|
+
return v.assignmentIds
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function parseResultBody(v: unknown): { id: string; state: AssignmentState; receiptId?: string; detail?: string; sessionID?: string }[] {
|
|
410
|
+
if (!obj(v) || !Array.isArray(v.assignments) || v.assignments.length < 1 || v.assignments.length > WORK_CHAT_MAX_ASSIGNMENTS) throw invalid('assignments')
|
|
411
|
+
return v.assignments.map((a: unknown) => {
|
|
412
|
+
if (!obj(a) || typeof a.id !== 'string' || !/^a[1-9]$/.test(a.id)) throw invalid('assignments.id')
|
|
413
|
+
if (!(ASSIGNMENT_STATES as readonly unknown[]).includes(a.state)) throw invalid('assignments.state')
|
|
414
|
+
if (a.receiptId !== undefined && a.receiptId !== null && !(typeof a.receiptId === 'string' && RECEIPT_ID.test(a.receiptId))) throw invalid('assignments.receiptId')
|
|
415
|
+
if (a.detail !== undefined && a.detail !== null && !str(a.detail, LENS_DETAIL_CHARS)) throw invalid('assignments.detail')
|
|
416
|
+
if (a.sessionID !== undefined && a.sessionID !== null && !(typeof a.sessionID === 'string' && SID.test(a.sessionID))) throw invalid('assignments.sessionID')
|
|
417
|
+
return { id: a.id, state: a.state, ...(a.receiptId ? { receiptId: a.receiptId } : {}), ...(typeof a.detail === 'string' ? { detail: a.detail } : {}),
|
|
418
|
+
...(a.sessionID ? { sessionID: a.sessionID } : {}) }
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The round as the lens reads it: state, never the prompts. */
|
|
423
|
+
export function roundView(r: LensRound) {
|
|
424
|
+
const send = r.sends[r.sends.length - 1]
|
|
425
|
+
let state: RoundState = send ? send.state : r.inHistory ? 'in_history' : 'recorded'
|
|
426
|
+
// The newest send was COS Control's own on the Mac: the round reads as that send's outcome.
|
|
427
|
+
if (r.mac && (!send || r.mac.at >= send.requestedAt)) {
|
|
428
|
+
const states = r.assignments.filter(a => r.mac!.ids.includes(a.id)).map(a => a.state)
|
|
429
|
+
state = states.length && states.every(s => TERMINAL.includes(s)) ? sendOutcome(states) : 'sending'
|
|
430
|
+
// Part of the plan sent from the Mac and the rest never sent: partly sent, and the rest may still go from the lens.
|
|
431
|
+
if (state === 'sent' && r.mac.ids.length < r.assignments.length) state = 'partly_sent'
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
roundId: r.roundId, workID: r.workID, state, inHistory: r.inHistory, autoSendCard: r.autoSendCard,
|
|
435
|
+
assignments: r.assignments.map(a => ({ id: a.id, state: a.state, ...(a.reportedSessionID ?? a.sessionID ? { sessionID: a.reportedSessionID ?? a.sessionID } : {}),
|
|
436
|
+
...(a.receiptId ? { receiptId: a.receiptId } : {}), ...(a.detail ? { detail: a.detail } : {}) })),
|
|
437
|
+
updatedAt: new Date(r.updatedAt).toISOString(),
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
let shared: LensRoundStore | null = null
|
|
442
|
+
/** The process-wide store. Inside a test it is in memory only, so a test never reads or moves this Mac's data file. */
|
|
443
|
+
export function defaultLensRoundStore(): LensRoundStore {
|
|
444
|
+
return shared ??= new LensRoundStore(process.env.VITEST || process.env.NODE_ENV === 'test' ? null : dataPath('work-lens-rounds.json'))
|
|
445
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.83.0 (M1, canary C3): which sessions belong to a Work card, from COS Control's snapshot and the tag index.
|
|
3
|
+
* Pure: no I/O. The rules and their ranks are in docs/work-orchestrator.md.
|
|
4
|
+
*/
|
|
5
|
+
import type { ControlSnapshot } from './snapshot.js'
|
|
6
|
+
import type { TagHit } from './tag-index.js'
|
|
7
|
+
import { tagForWorkID, tagsIn } from './tags.js'
|
|
8
|
+
|
|
9
|
+
/** Evidence kinds, strongest first. The first five make a session the card's; status and listed confirm one. */
|
|
10
|
+
export const EVIDENCE_RANK = ['link', 'created', 'forked', 'continued', 'prompt', 'status', 'listed', 'meeting'] as const
|
|
11
|
+
export type Evidence = typeof EVIDENCE_RANK[number]
|
|
12
|
+
const MEMBER: ReadonlySet<Evidence> = new Set(['link', 'created', 'forked', 'continued', 'prompt'])
|
|
13
|
+
const CONFIRMS: ReadonlySet<Evidence> = new Set(['status', 'listed'])
|
|
14
|
+
|
|
15
|
+
export interface CardSession {
|
|
16
|
+
id: string
|
|
17
|
+
/** Evidence kinds for this card, in rank order. */
|
|
18
|
+
discoveredBy: Evidence[]
|
|
19
|
+
/** True: one of ranks 1 to 5 holds. False: a suggestion (status or listed alone, or the source-meeting join). */
|
|
20
|
+
member: boolean
|
|
21
|
+
linked: boolean
|
|
22
|
+
/** Newest evidence for this card (ms). */
|
|
23
|
+
lastTouchedCard: number | null
|
|
24
|
+
/** Set when the session's newest strong evidence is on another card: it works there now, and keeps this one as history. */
|
|
25
|
+
onAnotherCard: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Ev { kind: Evidence; at: number | null }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Strong evidence per card tag and session, from Control's records and the logs.
|
|
32
|
+
* A placeholder or never-used tag (canary precision fix 7: `aaaaaaaaaaaa`, `0123456789ab`) is inert by construction: only
|
|
33
|
+
* the requested card's own tag is read for this card, and only member evidence (a Control record or a real handoff
|
|
34
|
+
* prompt) can make another card the newer owner, so a status line with an unknown tag never changes a row.
|
|
35
|
+
*/
|
|
36
|
+
function strongByTag(snapshot: ControlSnapshot | null, hits: TagHit[]): Map<string, Map<string, Ev[]>> {
|
|
37
|
+
const out = new Map<string, Map<string, Ev[]>>()
|
|
38
|
+
const add = (tag: string, sid: string | null, kind: Evidence, at: number | null) => {
|
|
39
|
+
if (!sid) return
|
|
40
|
+
let card = out.get(tag); if (!card) out.set(tag, card = new Map())
|
|
41
|
+
let list = card.get(sid); if (!list) card.set(sid, list = [])
|
|
42
|
+
list.push({ kind, at })
|
|
43
|
+
}
|
|
44
|
+
for (const l of snapshot?.links ?? []) add(tagForWorkID(l.workID), l.sessionID, 'link', l.at * 1000)
|
|
45
|
+
for (const r of snapshot?.receipts ?? []) {
|
|
46
|
+
const tag = tagForWorkID(r.workID), at = r.createdAt * 1000
|
|
47
|
+
if (r.mode === 'newSession') add(tag, r.sessionID, 'created', at)
|
|
48
|
+
else if (r.mode === 'fork') {
|
|
49
|
+
// A fork's child: journaled by Control 0.5.333, or a sessionID that is not the source (canary precision fix 1).
|
|
50
|
+
const child = r.forkChildID ?? (r.sessionID && r.sessionID !== r.sourceSessionID ? r.sessionID : null)
|
|
51
|
+
add(tag, child, 'forked', at)
|
|
52
|
+
} else add(tag, r.sessionID ?? r.sourceSessionID, 'continued', at)
|
|
53
|
+
// A sourceSessionID is where a fork or new session came FROM, never membership (precision fix 2).
|
|
54
|
+
}
|
|
55
|
+
for (const h of hits) {
|
|
56
|
+
if (h.kind === 'prompt') add(h.tag, h.sid, 'prompt', h.at)
|
|
57
|
+
else if (h.kind === 'status') add(h.tag, h.sid, 'status', h.at)
|
|
58
|
+
}
|
|
59
|
+
for (const row of snapshot?.live?.rows ?? []) {
|
|
60
|
+
for (const tag of tagsIn(row.summary)) add(tag, row.id, 'listed', row.updatedAt ? Date.parse(row.updatedAt) || null : null)
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const newest = (list: Ev[], kinds?: ReadonlySet<Evidence>) => list.filter(e => !kinds || kinds.has(e.kind)).reduce<number | null>((m, e) => (e.at !== null && (m === null || e.at > m) ? e.at : m), null)
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The card's sessions. `meetingWorkIDs`: the card's confirmed source meetings as meeting cards (`meeting:<recordId>`);
|
|
69
|
+
* a session that is a member of one of them is suggested here with the reason (rule 8).
|
|
70
|
+
*/
|
|
71
|
+
export function discoverCardSessions(workID: string, snapshot: ControlSnapshot | null, hits: TagHit[], meetingWorkIDs: string[] = []): CardSession[] {
|
|
72
|
+
const byTag = strongByTag(snapshot, hits)
|
|
73
|
+
const tag = tagForWorkID(workID)
|
|
74
|
+
const mine = new Map<string, Ev[]>(byTag.get(tag) ?? [])
|
|
75
|
+
for (const m of meetingWorkIDs) {
|
|
76
|
+
for (const [sid, list] of byTag.get(tagForWorkID(m)) ?? []) {
|
|
77
|
+
if (!list.some(e => MEMBER.has(e.kind))) continue
|
|
78
|
+
const at = newest(list, MEMBER)
|
|
79
|
+
mine.set(sid, [...(mine.get(sid) ?? []), { kind: 'meeting', at }])
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const out: CardSession[] = []
|
|
83
|
+
for (const [sid, list] of mine) {
|
|
84
|
+
const kinds = EVIDENCE_RANK.filter(k => list.some(e => e.kind === k))
|
|
85
|
+
const member = kinds.some(k => MEMBER.has(k))
|
|
86
|
+
if (!member && !kinds.some(k => CONFIRMS.has(k) || k === 'meeting')) continue
|
|
87
|
+
// Newest strong evidence decides "working on" (precision fix 5): another card with newer member evidence wins.
|
|
88
|
+
const here = newest(list, MEMBER) ?? newest(list)
|
|
89
|
+
let elsewhere: number | null = null
|
|
90
|
+
for (const [otherTag, sessions] of byTag) {
|
|
91
|
+
if (otherTag === tag || meetingWorkIDs.some(m => tagForWorkID(m) === otherTag)) continue
|
|
92
|
+
const t = newest(sessions.get(sid) ?? [], MEMBER)
|
|
93
|
+
if (t !== null && (elsewhere === null || t > elsewhere)) elsewhere = t
|
|
94
|
+
}
|
|
95
|
+
out.push({ id: sid, discoveredBy: kinds, member, linked: kinds.includes('link'), lastTouchedCard: newest(list),
|
|
96
|
+
onAnotherCard: elsewhere !== null && here !== null && elsewhere > here })
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.83.0: the orchestrator input for one Work card. Joins discovery, the live session list Control just read, the
|
|
3
|
+
* pick-up notes and the usage snapshot into the rows the planner sees, ranked and bounded, with what was left out and
|
|
4
|
+
* why. It sends nothing and changes nothing.
|
|
5
|
+
*/
|
|
6
|
+
import type { ControlSnapshot, SnapshotLiveRow } from './snapshot.js'
|
|
7
|
+
import { discoverCardSessions, type CardSession, type Evidence } from './discovery.js'
|
|
8
|
+
import { pickupNote, PICKUP_MAX_CHARS } from './pickup.js'
|
|
9
|
+
import type { UsageProvider, UsageView } from './usage.js'
|
|
10
|
+
import type { LogProvider } from './session-log.js'
|
|
11
|
+
|
|
12
|
+
export const ORCHESTRATOR_MAX_ROWS = 12
|
|
13
|
+
export const ORCHESTRATOR_MAX_LEFT_OUT = 12
|
|
14
|
+
/** The live list Control sends is "just read" for this long; older, rows read as not in the session list. */
|
|
15
|
+
export const LIVE_FRESH_MS = 3 * 60_000
|
|
16
|
+
|
|
17
|
+
export const UNAVAILABLE_REASONS = ['not_in_session_list', 'app_owned', 'cursor', 'plan_limit', 'on_another_card'] as const
|
|
18
|
+
export type UnavailableReason = typeof UNAVAILABLE_REASONS[number]
|
|
19
|
+
|
|
20
|
+
export interface OrchestratorRow {
|
|
21
|
+
id: string; title: string; summary: string; status: string
|
|
22
|
+
discoveredBy: Evidence[]; linked: boolean; member: boolean
|
|
23
|
+
lastTouchedCard: string | null; lastActivity: string | null
|
|
24
|
+
live: boolean; busy: boolean; pickup: string
|
|
25
|
+
available: boolean; reason?: UnavailableReason
|
|
26
|
+
/** A fork can start from it: Claude or Codex, its log is on this Mac, and that provider has plan usage left. */
|
|
27
|
+
forkable: boolean
|
|
28
|
+
}
|
|
29
|
+
export interface LeftOut { id: string; title: string; reason: string }
|
|
30
|
+
export interface OrchestratorInput {
|
|
31
|
+
protocolVersion: 1; workID: string; now: string
|
|
32
|
+
rows: OrchestratorRow[]; leftOut: LeftOut[]; usage: UsageView[]
|
|
33
|
+
snapshotAgeSec: number | null; liveListAgeSec: number | null; logScanComplete: boolean
|
|
34
|
+
discovered: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface InputDeps {
|
|
38
|
+
snapshot: ControlSnapshot | null
|
|
39
|
+
hits: import('./tag-index.js').TagHit[]
|
|
40
|
+
usage: UsageView[]
|
|
41
|
+
/** Log path for a session, or null when this Mac has none. */
|
|
42
|
+
pathOf: (sid: string) => Promise<string | null>
|
|
43
|
+
/** Last-modified time of a log, for sessions outside the live list. */
|
|
44
|
+
mtimeOf?: (path: string) => Promise<number | null>
|
|
45
|
+
skipped?: Set<string>
|
|
46
|
+
logScanComplete?: boolean
|
|
47
|
+
now?: number
|
|
48
|
+
home?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const iso = (ms: number | null | undefined) => (ms ? new Date(ms).toISOString() : null)
|
|
52
|
+
|
|
53
|
+
export async function buildOrchestratorInput(workID: string, meetingWorkIDs: string[], deps: InputDeps): Promise<OrchestratorInput> {
|
|
54
|
+
const now = deps.now ?? Date.now()
|
|
55
|
+
const snapshot = deps.snapshot
|
|
56
|
+
const liveFresh = !!snapshot?.live && now - snapshot.live.checkedAt * 1000 <= LIVE_FRESH_MS
|
|
57
|
+
const live = new Map<string, SnapshotLiveRow>((liveFresh ? snapshot!.live!.rows : []).map(r => [r.id, r]))
|
|
58
|
+
const usageBy = new Map<UsageProvider, UsageView>(deps.usage.map(u => [u.provider, u]))
|
|
59
|
+
const found = discoverCardSessions(workID, snapshot, deps.hits, meetingWorkIDs)
|
|
60
|
+
const leftOut: LeftOut[] = []
|
|
61
|
+
const candidates: Array<{ s: CardSession; row: SnapshotLiveRow | undefined }> = []
|
|
62
|
+
for (const s of found) {
|
|
63
|
+
if (deps.skipped?.has(s.id)) { leftOut.push({ id: s.id, title: '', reason: 'codex child thread (guardian or subagent)' }); continue }
|
|
64
|
+
candidates.push({ s, row: live.get(s.id) })
|
|
65
|
+
}
|
|
66
|
+
// Members before suggestions; in the live list before not; the session that touched this card most recently first.
|
|
67
|
+
candidates.sort((a, b) => Number(b.s.member) - Number(a.s.member) || Number(!!b.row) - Number(!!a.row)
|
|
68
|
+
|| Number(!b.s.onAnotherCard) - Number(!a.s.onAnotherCard) || (b.s.lastTouchedCard ?? 0) - (a.s.lastTouchedCard ?? 0) || a.s.id.localeCompare(b.s.id))
|
|
69
|
+
const kept = candidates.slice(0, ORCHESTRATOR_MAX_ROWS)
|
|
70
|
+
for (const c of candidates.slice(ORCHESTRATOR_MAX_ROWS)) leftOut.push({ id: c.s.id, title: c.row?.title ?? '', reason: 'more than 12 sessions on this card' })
|
|
71
|
+
const rows: OrchestratorRow[] = []
|
|
72
|
+
for (const { s, row } of kept) {
|
|
73
|
+
const provider = s.id.split(':')[0] as LogProvider
|
|
74
|
+
const path = await deps.pathOf(s.id).catch(() => null)
|
|
75
|
+
let pickup = 'No log for this session on this Mac.'
|
|
76
|
+
let lastActivity: string | null = row?.updatedAt ?? null
|
|
77
|
+
if (path) {
|
|
78
|
+
try {
|
|
79
|
+
const note = await pickupNote(path, provider, s.id.split(':')[1]!, row ? { busy: row.busy, waitingOnUser: row.waitingOnUser } : undefined, deps.home)
|
|
80
|
+
pickup = note.note.slice(0, PICKUP_MAX_CHARS)
|
|
81
|
+
lastActivity = lastActivity ?? note.lastActivity
|
|
82
|
+
} catch { pickup = 'The log could not be read.' }
|
|
83
|
+
if (!lastActivity && deps.mtimeOf) lastActivity = iso(await deps.mtimeOf(path).catch(() => null))
|
|
84
|
+
}
|
|
85
|
+
const usage = provider === 'cursor' ? undefined : usageBy.get(provider)
|
|
86
|
+
const exhausted = usage?.state === 'exhausted'
|
|
87
|
+
let reason: UnavailableReason | undefined
|
|
88
|
+
if (provider === 'cursor') reason = 'cursor'
|
|
89
|
+
else if (s.onAnotherCard) reason = 'on_another_card'
|
|
90
|
+
else if (exhausted) reason = 'plan_limit'
|
|
91
|
+
else if (!row) reason = 'not_in_session_list'
|
|
92
|
+
else if (row.appOwned) reason = 'app_owned'
|
|
93
|
+
rows.push({
|
|
94
|
+
id: s.id, title: (row?.title || '').slice(0, 300) || s.id.slice(0, 15), summary: (row?.summary ?? '').slice(0, 2000),
|
|
95
|
+
status: (row ? row.status || 'idle' : 'not in the session list').slice(0, 100),
|
|
96
|
+
discoveredBy: s.discoveredBy, linked: s.linked, member: s.member,
|
|
97
|
+
lastTouchedCard: iso(s.lastTouchedCard), lastActivity,
|
|
98
|
+
live: !!row, busy: !!row?.busy, pickup,
|
|
99
|
+
available: !reason, ...(reason ? { reason } : {}),
|
|
100
|
+
forkable: (provider === 'claude' || provider === 'codex') && !!path && !exhausted,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
protocolVersion: 1, workID, now: new Date(now).toISOString(), rows, leftOut: leftOut.slice(0, ORCHESTRATOR_MAX_LEFT_OUT), usage: deps.usage,
|
|
105
|
+
snapshotAgeSec: snapshot ? Math.round(now / 1000 - snapshot.receivedAt) : null,
|
|
106
|
+
liveListAgeSec: snapshot?.live ? Math.round(now / 1000 - snapshot.live.checkedAt) : null,
|
|
107
|
+
logScanComplete: deps.logScanComplete ?? true, discovered: found.length,
|
|
108
|
+
}
|
|
109
|
+
}
|