@gotcos/glasses-server 6.58.2 → 6.59.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 +29 -0
- package/docs/work-handoff-requests.md +181 -0
- package/docs/work-intake.md +2 -0
- package/docs/work-task-runtime.md +16 -6
- package/package.json +4 -2
- package/server/index.ts +12 -1
- package/server/lib/query-job-types.ts +11 -2
- package/server/lib/work-activity.ts +377 -39
- package/server/lib/work-board-reader.ts +63 -0
- package/server/lib/work-handoff-requests.ts +426 -0
- package/server/routes/work-board.ts +55 -10
- package/server/routes/work-handoff-requests.ts +159 -0
|
@@ -1,34 +1,257 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
1
2
|
import { constants, closeSync, fstatSync, lstatSync, openSync, readSync } from 'node:fs'
|
|
2
3
|
import { homedir } from 'node:os'
|
|
3
4
|
import { dirname, join, resolve } from 'node:path'
|
|
5
|
+
import { flattenDisplayText } from './query-job-types.js'
|
|
6
|
+
import { isValidNativeThreadId } from './native-thread-id.js'
|
|
7
|
+
import { isClaudeModel, isCodexModel, isCursorModel, isOllamaModel, normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
|
|
4
8
|
|
|
9
|
+
export type WorkHandoffMode = 'continueSession' | 'fork' | 'newSession'
|
|
10
|
+
/** 6.59.0: what COS Control's tracker recorded (its `WorkProgress`, Sources/WorkProgress.swift). */
|
|
11
|
+
export interface WorkActivityProgress {
|
|
12
|
+
reported: 'done' | 'needsInput' | 'blocked' | null
|
|
13
|
+
reportedBy?: 'session' | 'jev'
|
|
14
|
+
/** At most 280 characters and 1,200 UTF-16 units, cleaned as a session name is. The session's own status line, or Jev's reading. */
|
|
15
|
+
evidence?: string
|
|
16
|
+
receivedAt?: string
|
|
17
|
+
lastMove?: { from: string; to: string; at: string; undone: boolean }
|
|
18
|
+
/** A manual move back (or an Undo) paused automatic moves for this handoff. */
|
|
19
|
+
paused: boolean
|
|
20
|
+
}
|
|
21
|
+
/** A progress block the server could not read: there may be a report it cannot show. */
|
|
22
|
+
export interface UnreadableWorkProgress { unreadable: true }
|
|
5
23
|
export interface WorkActivity {
|
|
6
24
|
id: string; workId: string; domain: string; status: string
|
|
7
25
|
provider?: string; sessionId?: string; sessionTitle?: string; error?: string
|
|
26
|
+
// 6.59.0, all optional for older clients.
|
|
27
|
+
mode?: WorkHandoffMode
|
|
28
|
+
model?: string
|
|
29
|
+
createdAt?: string
|
|
30
|
+
updatedAt?: string
|
|
31
|
+
progress?: WorkActivityProgress | UnreadableWorkProgress
|
|
32
|
+
acknowledged?: boolean
|
|
33
|
+
/** Per SESSION: a receipt handed this handoff's session to its app (opened there, a 0.5.248 tab, or channel app). */
|
|
34
|
+
appOpened?: boolean
|
|
35
|
+
/** Per SESSION: the COS server is still running a Work New session on this handoff's session. */
|
|
36
|
+
serverHold?: boolean
|
|
37
|
+
/** Per SESSION: a note for this handoff's session is queued in its app (status queued, channel app); nothing reached the session. */
|
|
38
|
+
waitingInApp?: boolean
|
|
39
|
+
requestedFrom?: 'glasses'
|
|
40
|
+
/** The handoff request this receipt answered (Control 0.5.252 writes it). */
|
|
41
|
+
requestId?: string
|
|
42
|
+
/** How Control delivered it: job, fork, app, turn, queue or tab. */
|
|
43
|
+
channel?: string
|
|
44
|
+
/**
|
|
45
|
+
* A send in flight: on disk it reads preparing or sending and was made in the last 10 minutes. `status` still reads
|
|
46
|
+
* `unknown` (older clients); with this flag it is "Sending", not an unresolved delivery. Absent otherwise.
|
|
47
|
+
*/
|
|
48
|
+
sending?: true
|
|
49
|
+
}
|
|
50
|
+
/** 6.59.0 (S4): where Control's saved draft for the task's current revision sends. Never the draft's prompt. */
|
|
51
|
+
export interface SavedDestination { mode: WorkHandoffMode; provider: string; model?: string; sessionId?: string }
|
|
52
|
+
/**
|
|
53
|
+
* `requests` is 1 only while COS Control is consuming the inbox (it listed pending requests in the last 120 s);
|
|
54
|
+
* `requestsInbox` is 1 when the inbox opened on this server; `requestsConsumerSeenAt` is Control's last listing.
|
|
55
|
+
*/
|
|
56
|
+
export interface WorkActivityCapabilities { progress: 1; requests: 0 | 1; requestsInbox: 0 | 1; requestsConsumerSeenAt: string | null }
|
|
57
|
+
export interface WorkActivityResult {
|
|
58
|
+
version: 1; available: boolean; reason?: string; capabilities?: WorkActivityCapabilities
|
|
59
|
+
activities: WorkActivity[]; savedDestination?: SavedDestination
|
|
60
|
+
/** The task's own revision (Control's taskSnapshot), for a handoff request's expectedTaskRevision. */
|
|
61
|
+
taskRevision?: string
|
|
8
62
|
}
|
|
9
|
-
export interface WorkActivityResult { version: 1; available: boolean; reason?: string; activities: WorkActivity[] }
|
|
10
63
|
/** These options are dependency injection for fixtures, never request parameters. */
|
|
11
64
|
export interface WorkActivityOptions {
|
|
12
65
|
journalPath?: string; home?: string; platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv
|
|
13
66
|
}
|
|
14
|
-
|
|
67
|
+
type JournalRecord = Record<string, unknown>
|
|
68
|
+
/** A native journal that passed every safety and format check. Its receipts and drafts are the validated raw records. */
|
|
69
|
+
export interface WorkJournal { available: true; receipts: JournalRecord[]; drafts: JournalRecord[] }
|
|
70
|
+
export type WorkJournalRead = WorkJournal | { available: false; reason: string }
|
|
71
|
+
|
|
72
|
+
export const WORK_ACTIVITY_LIMITS = { evidence: 280, evidenceUnits: 1_200, openItems: 50, trackedDays: 14, progressEvents: 1_000, sendingSeconds: 600 } as const
|
|
73
|
+
/** Control's `TaskRow.workStages` (and task-store WORK_STAGES). A move naming anything else is not projected. */
|
|
74
|
+
export const WORK_ACTIVITY_STAGES: readonly string[] = ['mentioned', 'planned', 'draft', 'built', 'qa', 'complete']
|
|
75
|
+
const unavailableJournal = (reason: string): WorkJournalRead => ({ available: false, reason })
|
|
15
76
|
const statuses = new Set(['preparing', 'sending', 'queued', 'running', 'delivered', 'completed', 'failed', 'refused', 'reviewed', 'canceled', 'unknown'])
|
|
77
|
+
/** Control's `WorkHandoffReceipt.terminalStatuses`: never block another handoff. */
|
|
78
|
+
const terminal = new Set(['completed', 'failed', 'refused', 'canceled', 'reviewed'])
|
|
16
79
|
const providers = new Set(['claude', 'codex', 'cursor', 'ollama'])
|
|
80
|
+
/** Control's provider sets (WorkHandoffStore.swift): Continue, native Fork, Fork targets across platforms, Fork sources. */
|
|
81
|
+
export const CONTINUE_PROVIDERS: ReadonlySet<string> = new Set(['claude', 'codex', 'cursor'])
|
|
82
|
+
export const FORK_PROVIDERS: ReadonlySet<string> = new Set(['claude', 'codex'])
|
|
83
|
+
const CROSS_PLATFORM_TARGETS: ReadonlySet<string> = new Set(['claude', 'codex'])
|
|
84
|
+
const EXPORTABLE_PROVIDERS: ReadonlySet<string> = new Set(['claude', 'codex', 'cursor'])
|
|
85
|
+
/** Control's draftLimit: 32,000 less the status-line reserve, in UTF-16 units. */
|
|
86
|
+
const DRAFT_LIMIT = 32_000 - 480
|
|
87
|
+
const CHANNELS: ReadonlySet<string> = new Set(['job', 'fork', 'app', 'turn', 'queue', 'tab'])
|
|
88
|
+
export const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
|
17
89
|
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value)
|
|
18
90
|
const text = (value: unknown, max: number): value is string => typeof value === 'string' && value.length <= max
|
|
91
|
+
const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
|
|
92
|
+
const absentOr = (value: unknown, check: (v: unknown) => boolean): boolean => value === undefined || value === null || check(value)
|
|
93
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g
|
|
94
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
19
95
|
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
96
|
+
/**
|
|
97
|
+
* A Work session id exactly as a handoff request may name one: `provider:native` with provider claude, codex or cursor
|
|
98
|
+
* and native a full lowercase thread UUID (native-thread-id.ts). Never a short id, a path, or a nested `provider:`.
|
|
99
|
+
*/
|
|
100
|
+
export function parseWorkSessionId(value: unknown): { provider: string; native: string } | null {
|
|
101
|
+
if (typeof value !== 'string') return null
|
|
102
|
+
const at = value.indexOf(':'), provider = value.slice(0, at), native = value.slice(at + 1)
|
|
103
|
+
return at > 0 && CONTINUE_PROVIDERS.has(provider) && isValidNativeThreadId(native) ? { provider, native } : null
|
|
104
|
+
}
|
|
105
|
+
/** The provider a model slot runs on. */
|
|
106
|
+
export function modelProvider(slot: ModelPreference): 'claude' | 'codex' | 'cursor' | 'ollama' {
|
|
107
|
+
return isClaudeModel(slot) ? 'claude' : isCodexModel(slot) ? 'codex' : isCursorModel(slot) ? 'cursor' : isOllamaModel(slot) ? 'ollama' : 'claude'
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Control's ClaudeSession.sameSession (Models.swift): two `provider:native` ids name one session when the providers
|
|
111
|
+
* match and the native ids are equal, or one is the first 8 or more characters of the other. Case-insensitive.
|
|
23
112
|
*/
|
|
24
|
-
export function
|
|
113
|
+
export function sameSession(a: string, b: string): boolean {
|
|
114
|
+
const split = (v: string) => { const at = v.indexOf(':'); return at > 0 && at < v.length - 1 ? [v.slice(0, at).toLowerCase(), v.slice(at + 1).toLowerCase()] : null }
|
|
115
|
+
const x = split(a), y = split(b)
|
|
116
|
+
if (!x || !y || x[0] !== y[0]) return false
|
|
117
|
+
if (x[1] === y[1]) return true
|
|
118
|
+
const [short, long] = x[1].length <= y[1].length ? [x[1], y[1]] : [y[1], x[1]]
|
|
119
|
+
return short.length >= 8 && long.startsWith(short)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Control's clock (seconds since 1970) as ISO, or nothing for a value no Date can hold. */
|
|
123
|
+
function isoFromSeconds(seconds: unknown): string | undefined {
|
|
124
|
+
return finite(seconds) && seconds > 0 && seconds < 1e11 ? new Date(seconds * 1000).toISOString() : undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Cleaned as a session name is (6.58.2 sanitizeSessionName's characters: controls, zero-width space, direction marks
|
|
129
|
+
* and overrides as spaces, joiners kept, whitespace collapsed), then at most 280 whole characters AND 1,200 UTF-16
|
|
130
|
+
* units, so one character built of many marks cannot make it large. A cut ends in an ellipsis, as Control's clip does.
|
|
131
|
+
*/
|
|
132
|
+
export function cleanEvidence(value: unknown): string | undefined {
|
|
133
|
+
if (typeof value !== 'string') return undefined
|
|
134
|
+
const flat = flattenDisplayText(value)
|
|
135
|
+
if (!flat) return undefined
|
|
136
|
+
const chars = Array.from(graphemes.segment(flat), part => part.segment)
|
|
137
|
+
if (chars.length <= WORK_ACTIVITY_LIMITS.evidence && flat.length <= WORK_ACTIVITY_LIMITS.evidenceUnits) return flat
|
|
138
|
+
let kept = ''
|
|
139
|
+
for (const char of chars.slice(0, WORK_ACTIVITY_LIMITS.evidence - 1)) {
|
|
140
|
+
if (kept.length + char.length > WORK_ACTIVITY_LIMITS.evidenceUnits - 1) break
|
|
141
|
+
kept += char
|
|
142
|
+
}
|
|
143
|
+
return kept.trimEnd() + '…'
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The receipt's optional `progress` block, parsed on its own. Absent is undefined; a malformed block reads
|
|
148
|
+
* `{ unreadable: true }` and never makes the journal unavailable: Control writes it, and a newer Control may write more.
|
|
149
|
+
*/
|
|
150
|
+
function parseProgress(raw: unknown): { projection: WorkActivityProgress | UnreadableWorkProgress; newestAt?: number } | undefined {
|
|
151
|
+
if (raw === undefined || raw === null) return undefined
|
|
152
|
+
const unreadable = { projection: { unreadable: true as const } }
|
|
153
|
+
if (!object(raw)) return unreadable
|
|
154
|
+
const { reported, reportedBy, evidence, receivedAt, paused, events } = raw
|
|
155
|
+
if (!absentOr(reported, v => typeof v === 'string') || !absentOr(reportedBy, v => typeof v === 'string')
|
|
156
|
+
|| !absentOr(evidence, v => text(v, 100_000)) || !absentOr(receivedAt, finite) || !absentOr(paused, v => typeof v === 'boolean')
|
|
157
|
+
|| !(events === undefined || (Array.isArray(events) && events.length <= WORK_ACTIVITY_LIMITS.progressEvents))) return unreadable
|
|
158
|
+
const list = (events ?? []) as unknown[]
|
|
159
|
+
let newestAt: number | undefined
|
|
160
|
+
for (const event of list) {
|
|
161
|
+
if (!object(event) || !finite(event.at) || typeof event.kind !== 'string'
|
|
162
|
+
|| !absentOr(event.fromStage, v => typeof v === 'string') || !absentOr(event.toStage, v => typeof v === 'string')
|
|
163
|
+
|| !absentOr(event.undoneAt, finite)) return unreadable
|
|
164
|
+
newestAt = newestAt === undefined ? event.at : Math.max(newestAt, event.at)
|
|
165
|
+
}
|
|
166
|
+
// Control decodes a kind it does not know as a note, which is no report.
|
|
167
|
+
const state = reported === 'done' || reported === 'needsInput' || reported === 'blocked' ? reported : null
|
|
168
|
+
const projection: WorkActivityProgress = { reported: state, paused: paused === true }
|
|
169
|
+
if (state) {
|
|
170
|
+
if (reportedBy === 'session' || reportedBy === 'jev') projection.reportedBy = reportedBy
|
|
171
|
+
const quoted = cleanEvidence(evidence)
|
|
172
|
+
if (quoted) projection.evidence = quoted
|
|
173
|
+
}
|
|
174
|
+
const received = isoFromSeconds(receivedAt)
|
|
175
|
+
if (received) projection.receivedAt = received
|
|
176
|
+
// The newest move COS made, undone or not (Control's timeline order: events are appended).
|
|
177
|
+
const move = [...list].reverse().find(event => (event as JournalRecord).kind === 'moved') as JournalRecord | undefined
|
|
178
|
+
const moveAt = move && isoFromSeconds(move.at)
|
|
179
|
+
if (move && moveAt && WORK_ACTIVITY_STAGES.includes(move.fromStage as string) && WORK_ACTIVITY_STAGES.includes(move.toStage as string)) {
|
|
180
|
+
projection.lastMove = { from: move.fromStage as string, to: move.toStage as string, at: moveAt, undone: finite(move.undoneAt) }
|
|
181
|
+
}
|
|
182
|
+
return { projection, newestAt }
|
|
183
|
+
}
|
|
184
|
+
/** The report a progress projection carries, or null (none, or unreadable). */
|
|
185
|
+
export function reportOf(progress: WorkActivity['progress']): WorkActivityProgress['reported'] {
|
|
186
|
+
return progress && !('unreadable' in progress) ? progress.reported : null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A 0.5.248 tab that never reached a session. Control reads it as canceled on load (WorkHandoffStore.unsentTab). */
|
|
190
|
+
const unsentTab = (receipt: JournalRecord): boolean => receipt.channel === 'tab' && receipt.status === 'queued' && receipt.sessionID == null
|
|
191
|
+
/** The status Control shows for a receipt it loaded: an interrupted send is unknown, an unsent tab canceled. */
|
|
192
|
+
const loadedStatus = (receipt: JournalRecord): string => ['preparing', 'sending'].includes(receipt.status as string) ? 'unknown'
|
|
193
|
+
: unsentTab(receipt) ? 'canceled' : receipt.status as string
|
|
194
|
+
const sessionOf = (receipt: JournalRecord): string | null => typeof receipt.sessionID === 'string' && receipt.sessionID !== '' ? receipt.sessionID : null
|
|
195
|
+
/** Control's appOwner, plus a channel app receipt (a Continue it handed to the app): the app owns the session. */
|
|
196
|
+
const ownsInApp = (r: JournalRecord) => (object(r.appOpen) && finite(r.appOpen.openedAt)) || r.channel === 'tab' || r.channel === 'app'
|
|
197
|
+
/** Control's serverHolds: a Work New session the COS server is still running. */
|
|
198
|
+
const serverHolds = (r: JournalRecord) => r.channel === 'job' && r.mode === 'newSession' && typeof r.sessionID === 'string' && !terminal.has(r.status as string)
|
|
199
|
+
|
|
200
|
+
function projectReceipt(receipt: JournalRecord, domain: string, all: readonly JournalRecord[], nowMs: number): WorkActivity {
|
|
201
|
+
const raw = receipt.status as string, status = loadedStatus(receipt)
|
|
202
|
+
const activity: WorkActivity = { id: receipt.id as string, workId: receipt.workID as string, domain, status }
|
|
203
|
+
// Control writes preparing or sending just before it sends, for a few seconds. Older than 10 minutes, it was interrupted.
|
|
204
|
+
if (['preparing', 'sending'].includes(raw) && nowMs / 1000 - (receipt.createdAt as number) < WORK_ACTIVITY_LIMITS.sendingSeconds) activity.sending = true
|
|
205
|
+
// Native sessionIDs are provider-qualified. Refuse cross-provider identity;
|
|
206
|
+
// missing ownership stays missing rather than being inferred from titles.
|
|
207
|
+
if (providers.has(receipt.provider as string)) {
|
|
208
|
+
const provider = receipt.provider as string
|
|
209
|
+
activity.provider = provider
|
|
210
|
+
if (typeof receipt.sessionID === 'string' && receipt.sessionID.startsWith(provider + ':')
|
|
211
|
+
&& receipt.sessionID.length > provider.length + 1 && !/[\x00-\x1f\x7f]/.test(receipt.sessionID)) {
|
|
212
|
+
activity.sessionId = receipt.sessionID
|
|
213
|
+
activity.sessionTitle = (receipt.sessionTitle as string).replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 500)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (['unknown', 'failed', 'refused', 'canceled'].includes(status)) activity.error = status === 'unknown'
|
|
217
|
+
? 'Delivery is unresolved. Inspect the original session before trying again.'
|
|
218
|
+
: 'This handoff did not complete successfully. Open COS Control for details.'
|
|
219
|
+
// 6.59.0 (S1). Never prompt, detail, result or the work title.
|
|
220
|
+
activity.mode = receipt.mode as WorkHandoffMode
|
|
221
|
+
// Only a New session is sent with a model. Control records the placeholder "existing-session" for Continue and Fork.
|
|
222
|
+
const model = (receipt.modelID as string).replace(CONTROL, ' ').trim()
|
|
223
|
+
if (receipt.mode === 'newSession' && model && model !== 'existing-session') activity.model = model
|
|
224
|
+
const progress = parseProgress(receipt.progress)
|
|
225
|
+
const createdAt = isoFromSeconds(receipt.createdAt)
|
|
226
|
+
if (createdAt) {
|
|
227
|
+
activity.createdAt = createdAt
|
|
228
|
+
activity.updatedAt = progress?.newestAt !== undefined
|
|
229
|
+
? isoFromSeconds(Math.max(receipt.createdAt as number, progress.newestAt)) ?? createdAt : createdAt
|
|
230
|
+
}
|
|
231
|
+
if (progress) activity.progress = progress.projection
|
|
232
|
+
// Control's handledByYou: acknowledged, or an unknown or delivered handoff you marked reviewed.
|
|
233
|
+
activity.acknowledged = finite(receipt.acknowledgedAt) || raw === 'reviewed'
|
|
234
|
+
// Ownership is the SESSION's, across every receipt that names it (Control's appOwner and serverHold look at all of them).
|
|
235
|
+
const session = sessionOf(receipt)
|
|
236
|
+
const onSession = session ? all.filter(r => { const other = sessionOf(r); return other !== null && sameSession(other, session) }) : []
|
|
237
|
+
activity.appOpened = onSession.some(ownsInApp)
|
|
238
|
+
activity.serverHold = onSession.some(serverHolds)
|
|
239
|
+
activity.waitingInApp = onSession.some(r => r.channel === 'app' && loadedStatus(r) === 'queued')
|
|
240
|
+
if (receipt.requestedFrom === 'glasses') activity.requestedFrom = 'glasses'
|
|
241
|
+
if (typeof receipt.requestId === 'string' && UUID_V4.test(receipt.requestId)) activity.requestId = receipt.requestId
|
|
242
|
+
if (CHANNELS.has(receipt.channel as string)) activity.channel = receipt.channel as string
|
|
243
|
+
return activity
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Read the native journal with every safety and format check. No title/keyword matching, inferred target or writes. */
|
|
247
|
+
export function readWorkJournal(options: WorkActivityOptions = {}): WorkJournalRead {
|
|
25
248
|
const env = options.env ?? process.env, home = options.home ?? homedir()
|
|
26
249
|
if (!options.journalPath) {
|
|
27
|
-
if ((options.platform ?? process.platform) !== 'darwin') return
|
|
250
|
+
if ((options.platform ?? process.platform) !== 'darwin') return unavailableJournal('Native Work history is available only on its Mac host.')
|
|
28
251
|
if (env.NODE_ENV === 'test' || env.VITEST || env.COS_CONTROL_TEST_HOME || env.COS_CONTROL2_ISOLATED_PREVIEW === '1'
|
|
29
252
|
|| env.COS_WORK_CONNECTED_TEST === '1'
|
|
30
253
|
|| (env.COS_DATA_DIR && resolve(env.COS_DATA_DIR) !== join(home, '.cos-glasses', 'data'))) {
|
|
31
|
-
return
|
|
254
|
+
return unavailableJournal('This isolated runtime does not read the native Work history.')
|
|
32
255
|
}
|
|
33
256
|
}
|
|
34
257
|
const path = options.journalPath ?? join(home, 'Library', 'Application Support', 'COS Control', 'work-handoffs', 'handoffs.json')
|
|
@@ -39,37 +262,37 @@ export function readWorkActivity(domain: string, workIdentity: string, options:
|
|
|
39
262
|
let parent = dirname(resolve(path))
|
|
40
263
|
while (true) {
|
|
41
264
|
const stat = lstatSync(parent)
|
|
42
|
-
if (!stat.isDirectory() || stat.isSymbolicLink()) return
|
|
265
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return unavailableJournal('Native Work history is not safely readable.')
|
|
43
266
|
const next = dirname(parent); if (next === parent) break; parent = next
|
|
44
267
|
}
|
|
45
268
|
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
46
269
|
const stat = fstatSync(fd)
|
|
47
|
-
if (!stat.isFile() || stat.size >= 10_000_000 || (typeof process.getuid === 'function' && stat.uid !== process.getuid())) return
|
|
270
|
+
if (!stat.isFile() || stat.size >= 10_000_000 || (typeof process.getuid === 'function' && stat.uid !== process.getuid())) return unavailableJournal('Native Work history is not safely readable.')
|
|
48
271
|
const buffer = Buffer.alloc(Math.min(stat.size + 1, 10_000_000))
|
|
49
272
|
let count = 0
|
|
50
273
|
while (count < buffer.length) { const n = readSync(fd, buffer, count, buffer.length - count, null); if (!n) break; count += n }
|
|
51
274
|
const after = fstatSync(fd), raw = buffer.subarray(0, count)
|
|
52
|
-
if (count !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || raw.length >= 10_000_000) return
|
|
275
|
+
if (count !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || raw.length >= 10_000_000) return unavailableJournal('Native Work history exceeds the supported limit.')
|
|
53
276
|
const journal: unknown = JSON.parse(raw.toString('utf8'))
|
|
54
277
|
if (!object(journal) || ![1, 2].includes(journal.version as number) || !Array.isArray(journal.receipts)
|
|
55
278
|
|| journal.receipts.length > 10_000 || !Array.isArray(journal.sessions)
|
|
56
|
-
|| (journal.drafts !== undefined && !Array.isArray(journal.drafts))) return
|
|
279
|
+
|| (journal.drafts !== undefined && !Array.isArray(journal.drafts))) return unavailableJournal('Native Work history has an unsupported format.')
|
|
57
280
|
// Match native Codable's required session/draft fields. A malformed journal
|
|
58
281
|
// is unavailable even when its receipt array happens to look plausible.
|
|
59
282
|
for (const session of journal.sessions) {
|
|
60
283
|
if (!object(session) || !['id','nativeID','provider','title','summary','project','status'].every(key => text(session[key], 100_000))
|
|
61
|
-
|| ['waitingDetail','failure'].some(key => session[key] != null && !text(session[key], 100_000))) return
|
|
284
|
+
|| ['waitingDetail','failure'].some(key => session[key] != null && !text(session[key], 100_000))) return unavailableJournal('Native Work history has an unsupported format.')
|
|
62
285
|
}
|
|
63
|
-
const draftKeys = new Set<string>()
|
|
286
|
+
const draftKeys = new Set<string>(), drafts: JournalRecord[] = []
|
|
64
287
|
for (const draft of journal.drafts ?? []) {
|
|
65
288
|
if (!object(draft) || !['sourceID','sourceRevision','sessionID','provider','modelID','prompt'].every(key => text(draft[key], 100_000))
|
|
66
289
|
|| !draft.sourceID || !draft.sourceRevision || !['continueSession','fork','newSession'].includes(draft.mode as string)
|
|
67
|
-
|| typeof draft.editVersion !== 'number' || !Number.isSafeInteger(draft.editVersion) || draft.editVersion < 0) return
|
|
290
|
+
|| typeof draft.editVersion !== 'number' || !Number.isSafeInteger(draft.editVersion) || draft.editVersion < 0) return unavailableJournal('Native Work history has an unsupported format.')
|
|
68
291
|
const key = JSON.stringify([draft.sourceID, draft.sourceRevision])
|
|
69
|
-
if (draftKeys.has(key)) return
|
|
70
|
-
draftKeys.add(key)
|
|
292
|
+
if (draftKeys.has(key)) return unavailableJournal('Native Work history has an unsupported format.')
|
|
293
|
+
draftKeys.add(key); drafts.push(draft)
|
|
71
294
|
}
|
|
72
|
-
const seen = new Set<string>(),
|
|
295
|
+
const seen = new Set<string>(), receipts: JournalRecord[] = []
|
|
73
296
|
for (const receipt of journal.receipts) {
|
|
74
297
|
if (!object(receipt) || !text(receipt.id, 256) || !receipt.id || seen.has(receipt.id)
|
|
75
298
|
|| !text(receipt.workID, 1024) || !text(receipt.workTitle, 8000) || !text(receipt.sourceRevision, 1024)
|
|
@@ -79,28 +302,143 @@ export function readWorkActivity(domain: string, workIdentity: string, options:
|
|
|
79
302
|
|| !text(receipt.detail, 100_000) || !text(receipt.prompt, 100_000)
|
|
80
303
|
|| typeof receipt.createdAt !== 'number' || !Number.isFinite(receipt.createdAt)
|
|
81
304
|
|| ['sessionID','result','sourceSessionID','bindingID','boundTo','channel','jobID','serverInstanceID'].some(key => receipt[key] != null && !text(receipt[key], 1_000_000))
|
|
82
|
-
|| (receipt.epoch != null && (typeof receipt.epoch !== 'number' || !Number.isSafeInteger(receipt.epoch)))) return
|
|
83
|
-
seen.add(receipt.id)
|
|
84
|
-
if (receipt.workID !== workId) continue
|
|
85
|
-
const status = ['preparing', 'sending'].includes(receipt.status as string) ? 'unknown' : receipt.status as string
|
|
86
|
-
const activity: WorkActivity = { id: receipt.id, workId, domain, status }
|
|
87
|
-
// Native sessionIDs are provider-qualified. Refuse cross-provider identity;
|
|
88
|
-
// missing ownership stays missing rather than being inferred from titles.
|
|
89
|
-
if (providers.has(receipt.provider)) {
|
|
90
|
-
activity.provider = receipt.provider
|
|
91
|
-
if (typeof receipt.sessionID === 'string' && receipt.sessionID.startsWith(receipt.provider + ':')
|
|
92
|
-
&& receipt.sessionID.length > receipt.provider.length + 1 && !/[\x00-\x1f\x7f]/.test(receipt.sessionID)) {
|
|
93
|
-
activity.sessionId = receipt.sessionID
|
|
94
|
-
activity.sessionTitle = receipt.sessionTitle.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 500)
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
if (['unknown', 'failed', 'refused', 'canceled'].includes(status)) activity.error = status === 'unknown'
|
|
98
|
-
? 'Delivery is unresolved. Inspect the original session before trying again.'
|
|
99
|
-
: 'This handoff did not complete successfully. Open COS Control for details.'
|
|
100
|
-
activities.push(activity)
|
|
305
|
+
|| (receipt.epoch != null && (typeof receipt.epoch !== 'number' || !Number.isSafeInteger(receipt.epoch)))) return unavailableJournal('Native Work history has an unsupported format.')
|
|
306
|
+
seen.add(receipt.id); receipts.push(receipt)
|
|
101
307
|
}
|
|
102
|
-
return {
|
|
308
|
+
return { available: true, receipts, drafts }
|
|
103
309
|
} catch (error) {
|
|
104
|
-
return
|
|
310
|
+
return unavailableJournal((error as NodeJS.ErrnoException).code === 'ENOENT' ? 'No native Work history is available on this host.' : 'Native Work history could not be read.')
|
|
105
311
|
} finally { if (fd !== undefined) closeSync(fd) }
|
|
106
312
|
}
|
|
313
|
+
|
|
314
|
+
/** Every receipt for one exact work item, in journal order. `nowMs` only tells a send in flight from an interrupted one. */
|
|
315
|
+
export function projectWorkActivity(journal: WorkJournal, domain: string, workIdentity: string, nowMs: number): WorkActivity[] {
|
|
316
|
+
const workId = `task:${domain}:${workIdentity}`
|
|
317
|
+
return journal.receipts.filter(receipt => receipt.workID === workId).map(receipt => projectReceipt(receipt, domain, journal.receipts, nowMs))
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The board-row fields Control reads for a task (TaskRow), and the ones the open list needs. */
|
|
321
|
+
export interface WorkBoardRowLite {
|
|
322
|
+
id: string; domain: string; title?: string; text?: string; source?: string; doneWhen?: string
|
|
323
|
+
checked?: boolean; workStage?: string; workIdentity?: string
|
|
324
|
+
meetingRefs?: Array<{ recordId?: unknown; domain?: unknown; month?: unknown; filename?: unknown; title?: unknown }>
|
|
325
|
+
}
|
|
326
|
+
const controlOrFormat = /[\p{Cc}\p{Cf}]/u
|
|
327
|
+
/** Control's WorkMeetingReference(JSONValue): refs it cannot read are left out of the snapshot. */
|
|
328
|
+
function controlMeetingRef(ref: unknown): { recordId: string; domain: string; month: string; filename: string; title: string } | null {
|
|
329
|
+
if (!object(ref)) return null
|
|
330
|
+
const { recordId, domain, month, filename, title } = ref
|
|
331
|
+
if (typeof recordId !== 'string' || !recordId || Buffer.byteLength(recordId) > 2048 || controlOrFormat.test(recordId)
|
|
332
|
+
|| typeof domain !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(domain)
|
|
333
|
+
|| typeof month !== 'string' || !/^[0-9]{4}-(0[1-9]|1[0-2])$/.test(month)
|
|
334
|
+
|| typeof filename !== 'string' || !filename || Buffer.byteLength(filename) > 1024 || filename === '.' || filename === '..'
|
|
335
|
+
|| filename.includes('/') || filename.includes('\\') || controlOrFormat.test(filename)) return null
|
|
336
|
+
return { recordId, domain, month, filename, title: typeof title === 'string' ? title : 'Saved meeting' }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Control's `WorkSource.taskSnapshot(task).revision` for a board row: the SHA-256 of the context it sends. A draft is
|
|
341
|
+
* saved against this revision, Control uses only the draft for the task's current one (`draft(for:)`), and a handoff
|
|
342
|
+
* request names it as `expectedTaskRevision`. Pinned to Control's own Swift in __fixtures__/control-task-snapshot.
|
|
343
|
+
*/
|
|
344
|
+
export function controlSnapshotRevision(row: WorkBoardRowLite): string {
|
|
345
|
+
const title = typeof row.title === 'string' ? row.title : ''
|
|
346
|
+
const words = typeof row.text === 'string' ? row.text : title
|
|
347
|
+
const str = (value: unknown) => typeof value === 'string' ? value : ''
|
|
348
|
+
let context = `Task: ${words === '' ? title : words}\nProject: ${str(row.domain)}\nDone when: ${str(row.doneWhen)}\nSource: ${str(row.source)}`
|
|
349
|
+
const refs = (Array.isArray(row.meetingRefs) ? row.meetingRefs : []).map(controlMeetingRef).filter(ref => ref !== null)
|
|
350
|
+
if (refs.length) {
|
|
351
|
+
context += '\nConfirmed meeting references (explicit links; transcript evidence is not included):\n'
|
|
352
|
+
+ refs.map(ref => `- ${ref.title} | canonical ID: ${ref.recordId} | saved source: ${ref.domain}/${ref.month}/${ref.filename}`).join('\n')
|
|
353
|
+
}
|
|
354
|
+
return createHash('sha256').update(context, 'utf8').digest('hex')
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* A draft's destination, only when Control's sendPlan (WorkHandoffStore.swift) would send exactly that AND a handoff
|
|
359
|
+
* request with it would pass this server's rules. The prompt is read only for being there; it is never returned.
|
|
360
|
+
*/
|
|
361
|
+
function draftDestination(draft: JournalRecord): SavedDestination | undefined {
|
|
362
|
+
const mode = draft.mode as WorkHandoffMode, provider = draft.provider as string, prompt = draft.prompt as string
|
|
363
|
+
if (!prompt.trim() || prompt.length > DRAFT_LIMIT) return undefined
|
|
364
|
+
const slot = normalizeModelPreference(draft.modelID)
|
|
365
|
+
if (mode === 'newSession') return slot && modelProvider(slot) === provider ? { mode, provider, model: slot } : undefined
|
|
366
|
+
const session = parseWorkSessionId(draft.sessionID)
|
|
367
|
+
if (!session) return undefined
|
|
368
|
+
const sessionId = draft.sessionID as string
|
|
369
|
+
if (mode === 'continueSession') return { mode, provider: session.provider, sessionId }
|
|
370
|
+
// Fork. Across platforms when the draft names another target platform: Control needs an exportable source and a
|
|
371
|
+
// model of that target; a request takes a Fork only from claude or codex. Otherwise a native Fork, whatever other
|
|
372
|
+
// provider the draft still holds.
|
|
373
|
+
if (CROSS_PLATFORM_TARGETS.has(provider) && provider !== session.provider) {
|
|
374
|
+
if (!EXPORTABLE_PROVIDERS.has(session.provider) || !FORK_PROVIDERS.has(session.provider) || !slot || modelProvider(slot) !== provider) return undefined
|
|
375
|
+
return { mode, provider, model: slot, sessionId }
|
|
376
|
+
}
|
|
377
|
+
return FORK_PROVIDERS.has(session.provider) ? { mode, provider: session.provider, sessionId } : undefined
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** S4: the destination saved in Control's draft for this task's current revision, when it names one. */
|
|
381
|
+
export function savedDestinationFor(journal: WorkJournal, row: WorkBoardRowLite): SavedDestination | undefined {
|
|
382
|
+
const sourceID = `task:${row.domain}:${row.workIdentity || row.id}`, revision = controlSnapshotRevision(row)
|
|
383
|
+
const draft = journal.drafts.find(d => d.sourceID === sourceID && d.sourceRevision === revision)
|
|
384
|
+
return draft ? draftDestination(draft) : undefined
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** S2 groups, in order. */
|
|
388
|
+
export type OpenWorkGroup = 'needsInput' | 'attention' | 'running' | 'done'
|
|
389
|
+
export interface OpenWorkItem extends WorkActivity { workIdentity: string; title: string; taskRevision: string; group: OpenWorkGroup; savedDestination?: SavedDestination }
|
|
390
|
+
export interface OpenWorkList { items: OpenWorkItem[]; total: number; truncated: boolean }
|
|
391
|
+
const GROUP_RANK: Record<OpenWorkGroup, number> = { needsInput: 0, attention: 1, running: 2, done: 3 }
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* S2: every board task whose newest handoff (any revision), sent in the last 14 days, still wants something:
|
|
395
|
+
* - needsInput: it reports needs input or blocked and you have not acknowledged it;
|
|
396
|
+
* - attention: failed, refused, unknown, or completed with no report, and not acknowledged;
|
|
397
|
+
* - running: queued, running or delivered, or a send in flight (preparing or sending, made in the last 10 minutes;
|
|
398
|
+
* an older one was interrupted and is unknown);
|
|
399
|
+
* - done: it reports done and you have not acknowledged it.
|
|
400
|
+
* A report counts only on a receipt that did not fail, was not refused and not canceled (Control's WorkTracking.latest).
|
|
401
|
+
* A checked task shows only while its handoff is queued or running (Control's board). Groups in that order, the most
|
|
402
|
+
* recently updated first within each, cut to 50 after sorting; `total` counts them all. Read-only.
|
|
403
|
+
*/
|
|
404
|
+
export function projectOpenWork(journal: WorkJournal, rows: readonly WorkBoardRowLite[], nowMs: number): OpenWorkList {
|
|
405
|
+
const newest = new Map<string, JournalRecord>()
|
|
406
|
+
for (const receipt of journal.receipts) {
|
|
407
|
+
const prior = newest.get(receipt.workID as string)
|
|
408
|
+
// Control's order: createdAt, then id.
|
|
409
|
+
if (!prior || (receipt.createdAt as number) > (prior.createdAt as number)
|
|
410
|
+
|| (receipt.createdAt === prior.createdAt && (receipt.id as string) > (prior.id as string))) newest.set(receipt.workID as string, receipt)
|
|
411
|
+
}
|
|
412
|
+
const nowSeconds = nowMs / 1000, ranked: Array<{ item: OpenWorkItem; rank: number; updatedAt: number; createdAt: number }> = []
|
|
413
|
+
const listed = new Set<string>()
|
|
414
|
+
for (const row of rows) {
|
|
415
|
+
const identity = row.workIdentity || row.id, workId = `task:${row.domain}:${identity}`
|
|
416
|
+
if (!/^[a-f0-9]{12}$/.test(identity) || listed.has(workId)) continue
|
|
417
|
+
listed.add(workId)
|
|
418
|
+
const receipt = newest.get(workId)
|
|
419
|
+
if (!receipt || nowSeconds - (receipt.createdAt as number) >= WORK_ACTIVITY_LIMITS.trackedDays * 86_400) continue
|
|
420
|
+
const activity = projectReceipt(receipt, row.domain, journal.receipts, nowMs)
|
|
421
|
+
const status = activity.status, handled = activity.acknowledged === true
|
|
422
|
+
const reported = ['failed', 'refused', 'canceled'].includes(status) ? null : reportOf(activity.progress)
|
|
423
|
+
// A send in flight is running, not unresolved.
|
|
424
|
+
const sending = activity.sending === true
|
|
425
|
+
const group: OpenWorkGroup | null = (reported === 'needsInput' || reported === 'blocked') && !handled ? 'needsInput'
|
|
426
|
+
: reported === 'done' && !handled ? 'done'
|
|
427
|
+
: sending ? 'running'
|
|
428
|
+
: !handled && (['failed', 'refused', 'unknown'].includes(status) || (status === 'completed' && reported === null)) ? 'attention'
|
|
429
|
+
: ['queued', 'running', 'delivered'].includes(status) ? 'running' : null
|
|
430
|
+
if (!group) continue
|
|
431
|
+
// Control drops a completed task's card unless its handoff is still in progress.
|
|
432
|
+
if (row.checked === true && !sending && !['queued', 'running'].includes(status)) continue
|
|
433
|
+
const item: OpenWorkItem = { ...activity, workIdentity: identity, title: typeof row.title === 'string' ? row.title : '', taskRevision: controlSnapshotRevision(row), group }
|
|
434
|
+
const destination = savedDestinationFor(journal, row)
|
|
435
|
+
if (destination) item.savedDestination = destination
|
|
436
|
+
const updatedAt = activity.updatedAt ? Date.parse(activity.updatedAt) : 0
|
|
437
|
+
ranked.push({ item, rank: GROUP_RANK[group], updatedAt, createdAt: receipt.createdAt as number })
|
|
438
|
+
}
|
|
439
|
+
const items = ranked
|
|
440
|
+
.sort((a, b) => a.rank - b.rank || b.updatedAt - a.updatedAt || b.createdAt - a.createdAt || (a.item.id < b.item.id ? 1 : a.item.id > b.item.id ? -1 : 0))
|
|
441
|
+
.slice(0, WORK_ACTIVITY_LIMITS.openItems)
|
|
442
|
+
.map(entry => entry.item)
|
|
443
|
+
return { items, total: ranked.length, truncated: ranked.length > items.length }
|
|
444
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.59.0: one board read shared by the glasses' Work routes, bounded in time.
|
|
3
|
+
*
|
|
4
|
+
* The board comes from the COS task bridge, one process per domain. It usually answers in about 0.1 s (88 to 101 ms
|
|
5
|
+
* measured on 2026-09-30), but the bridge's own timeout is 12 s, and the glasses give up on a request after about 8 to
|
|
6
|
+
* 10 s. So the activity, open-work and handoff-request routes wait at most `timeoutMs` (5 s) and then answer without
|
|
7
|
+
* the board (503 for a request, which then creates nothing). A read that outlives its caller keeps running and fills
|
|
8
|
+
* the cache, so the next call is fast. Concurrent callers share one read.
|
|
9
|
+
*
|
|
10
|
+
* The cache's age counts from the moment a read FINISHED, so a slow read is still served afterwards. A read that began
|
|
11
|
+
* before the one already cached never replaces it, and `invalidate()` (after a Work stage or meeting write) drops the
|
|
12
|
+
* cache and every read that began before it. GET /api/work-board keeps its own unbounded read (COS Control waits for
|
|
13
|
+
* it) and primes this cache with what it read.
|
|
14
|
+
*/
|
|
15
|
+
export const WORK_BOARD_READ_LIMITS = { timeoutMs: 5_000, savedDestinationMs: 3_000 } as const
|
|
16
|
+
|
|
17
|
+
export class WorkBoardTimeoutError extends Error {
|
|
18
|
+
constructor() { super('work_board_timeout') }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** When a read began, and which invalidation it began after. */
|
|
22
|
+
export interface WorkBoardReadToken { startedAt: number; generation: number }
|
|
23
|
+
export interface WorkBoardReader<Row> {
|
|
24
|
+
/** Rows whose read finished no more than `maxAgeMs` ago, a read already running, or a new read; rejects after `timeoutMs`. */
|
|
25
|
+
read(maxAgeMs: number, timeoutMs?: number): Promise<Row[]>
|
|
26
|
+
/** Mark the start of a read made elsewhere (GET /api/work-board), to hand to `prime`. */
|
|
27
|
+
begin(): WorkBoardReadToken
|
|
28
|
+
/** Keep the rows of a read made elsewhere. */
|
|
29
|
+
prime(rows: Row[], token: WorkBoardReadToken): void
|
|
30
|
+
/** The board was just written: nothing read before now may be served. */
|
|
31
|
+
invalidate(): void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createWorkBoardReader<Row>(list: () => Promise<Row[]>, options: { timeoutMs?: number; now?: () => number } = {}): WorkBoardReader<Row> {
|
|
35
|
+
const defaultTimeoutMs = options.timeoutMs ?? WORK_BOARD_READ_LIMITS.timeoutMs, now = options.now ?? Date.now
|
|
36
|
+
let generation = 0
|
|
37
|
+
let cache: { startedAt: number; endedAt: number; rows: Row[] } | undefined
|
|
38
|
+
let inflight: { generation: number; promise: Promise<Row[]> } | undefined
|
|
39
|
+
const keep = (rows: Row[], token: WorkBoardReadToken) => {
|
|
40
|
+
if (token.generation !== generation) return // it began before a write: it may show the board as it was
|
|
41
|
+
if (cache && token.startedAt < cache.startedAt) return // a newer read is already cached
|
|
42
|
+
cache = { startedAt: token.startedAt, endedAt: now(), rows }
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
read(maxAgeMs, timeoutMs = defaultTimeoutMs) {
|
|
46
|
+
const at = now()
|
|
47
|
+
if (cache && at - cache.endedAt <= maxAgeMs) return Promise.resolve(cache.rows)
|
|
48
|
+
if (!inflight || inflight.generation !== generation) {
|
|
49
|
+
const token = { startedAt: at, generation }
|
|
50
|
+
const promise = Promise.resolve().then(list).then(rows => { keep(rows, token); return rows })
|
|
51
|
+
inflight = { generation, promise }
|
|
52
|
+
promise.catch(() => {}).finally(() => { if (inflight?.promise === promise) inflight = undefined })
|
|
53
|
+
}
|
|
54
|
+
const running = inflight.promise
|
|
55
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
56
|
+
const bound = new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new WorkBoardTimeoutError()), timeoutMs) })
|
|
57
|
+
return Promise.race([running, bound]).finally(() => clearTimeout(timer))
|
|
58
|
+
},
|
|
59
|
+
begin() { return { startedAt: now(), generation } },
|
|
60
|
+
prime(rows, token) { keep(rows, token) },
|
|
61
|
+
invalidate() { generation++; cache = undefined },
|
|
62
|
+
}
|
|
63
|
+
}
|