@gotcos/glasses-server 6.98.1 → 6.99.16
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 +227 -0
- package/package.json +2 -1
- package/server/index.ts +170 -11
- package/server/lib/api-auth.ts +31 -14
- package/server/lib/claude-bridge.ts +21 -2
- package/server/lib/codex-bridge.ts +21 -2
- package/server/lib/cos-thread.ts +582 -0
- package/server/lib/display-bus.ts +3 -0
- package/server/lib/drop-attachments.ts +55 -0
- package/server/lib/drop-destinations.ts +134 -0
- package/server/lib/drop-history-search.ts +97 -0
- package/server/lib/drop-p2-boot.ts +60 -0
- package/server/lib/drop-p2-runtime.ts +237 -0
- package/server/lib/drop-p2-settings.ts +174 -0
- package/server/lib/drop-resolution.ts +168 -0
- package/server/lib/drop-router.ts +325 -0
- package/server/lib/drop-service.ts +830 -0
- package/server/lib/drop-settings.ts +56 -0
- package/server/lib/drop-store.ts +450 -0
- package/server/lib/drop-when.ts +265 -0
- package/server/lib/glasses-pairing.ts +15 -0
- package/server/lib/learning-needs-you.ts +111 -0
- package/server/lib/live-cues-v2-settings.ts +2 -1
- package/server/lib/live-cues-v2.ts +6 -4
- package/server/lib/media-store.ts +80 -0
- package/server/lib/meeting-batch-transcribe.ts +15 -2
- package/server/lib/meeting-consolidation-run.ts +169 -0
- package/server/lib/meeting-store.ts +61 -0
- package/server/lib/meeting-title-steer.ts +200 -0
- package/server/lib/network-policy.ts +84 -6
- package/server/lib/permission-broker.ts +13 -0
- package/server/lib/phone-recording.ts +1 -1
- package/server/lib/ping-budget.ts +149 -0
- package/server/lib/ping-engine.ts +245 -0
- package/server/lib/ping-feed-sink.ts +33 -0
- package/server/lib/ping-sources.ts +192 -0
- package/server/lib/query-job-coordinator.ts +5 -2
- package/server/lib/query-job-runtime.ts +45 -4
- package/server/lib/query-job-types.ts +10 -1
- package/server/lib/reminder-scheduler.ts +153 -0
- package/server/lib/tailnet-self-name.ts +103 -0
- package/server/lib/tailscale-nudge.ts +40 -2
- package/server/lib/task-store.ts +32 -0
- package/server/lib/web-ask-admission.ts +56 -0
- package/server/lib/web-live-chunks.ts +273 -0
- package/server/lib/web-memories-bridge.ts +323 -0
- package/server/lib/web-session-auth.ts +492 -0
- package/server/lib/web-usage-bridge.ts +154 -0
- package/server/lib/work-search.ts +14 -1
- package/server/lib/your-move-count.ts +58 -0
- package/server/routes/cos-thread.ts +79 -0
- package/server/routes/display.ts +11 -1
- package/server/routes/drops.ts +262 -0
- package/server/routes/health.ts +16 -0
- package/server/routes/live-cues.ts +5 -4
- package/server/routes/meeting.ts +61 -5
- package/server/routes/permission-broker.ts +23 -3
- package/server/routes/transcribe-stream.ts +43 -4
- package/server/routes/web-control.ts +569 -0
- package/server/routes/welcome-context.ts +2 -1
- package/server/scripts/web-live-cls/cdp.mjs +57 -0
- package/server/scripts/web-live-cls/measure.mjs +80 -0
- package/server/scripts/web-live-cls/mock.mjs +97 -0
- package/server/scripts/web-live-cls/render.mjs +26 -0
- package/server/web-control/app.css +519 -0
- package/server/web-control/app.js +3643 -0
- package/server/web-control/control.css +325 -0
- package/server/web-control/fonts/DMSans.ttf +0 -0
- package/server/web-control/fonts/Fraunces.ttf +0 -0
- package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
- package/server/web-control/index.html +275 -0
- package/server/web-control/mark.svg +1 -0
- package/server/web-control/memories/d3.min.js +2 -0
- package/server/web-control/memories/graph-explorer.css +200 -0
- package/server/web-control/memories/graph-explorer.js +748 -0
- package/server/web-control/memories/memories-app.js +1561 -0
- package/server/web-control/memories/memories-theme.css +73 -0
- package/server/web-control/memories/memories.html +72 -0
- package/server/web-control/memories/memory-stewardship.js +63 -0
- package/server/web-control/memories/memory-workspace.js +216 -0
- package/server/web-control/panes/cos-chat.css +174 -0
- package/server/web-control/panes/cos-chat.js +1118 -0
- package/server/web-control/panes/dictation.js +173 -0
- package/server/web-control/panes/meetings.css +314 -0
- package/server/web-control/panes/meetings.js +2568 -0
- package/server/web-control/panes/messages.js +718 -0
- package/server/web-control/panes/questions.css +32 -0
- package/server/web-control/panes/questions.js +275 -0
- package/server/web-control/panes/sessions.css +452 -0
- package/server/web-control/panes/settings.js +292 -0
- package/server/web-control/panes/speakers.css +269 -0
- package/server/web-control/panes/speakers.js +2755 -0
- package/server/web-control/panes/threads.js +200 -0
- package/server/web-control/panes/work.css +261 -0
- package/server/web-control/panes/work.js +1667 -0
- package/server/web-control/shell.js +294 -0
- package/server/web-control/theme.js +25 -0
- package/server/web-control/vendor/qrcode-encoder.js +10 -0
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { resolveProviderWorkDir } from './launch-dir.js'
|
|
2
|
+
import { COS_SCRIPTS_DIR } from './python-bridge.js'
|
|
3
|
+
import { consolidationModelRunner } from './meeting-consolidation-run.js'
|
|
1
4
|
import { routineAuditCaller } from './routines-config.js'
|
|
2
5
|
import { jobAuditCaller } from './job-audit-caller.js'
|
|
3
6
|
import { carriesBoundTo } from './agent-session-binding-store.js'
|
|
@@ -8,7 +11,9 @@ import { emitDisplay } from './display-bus.js'
|
|
|
8
11
|
import { resolveQueryAttachments } from './query-attachments.js'
|
|
9
12
|
import { getMediaStore } from './media-store.js'
|
|
10
13
|
import { dataPath } from './data-dir.js'
|
|
11
|
-
import { currentMessageEra, LEGACY_MESSAGE_ERA } from './message-era.js'
|
|
14
|
+
import { currentMessageEra, exchangeBelongsToEra, LEGACY_MESSAGE_ERA } from './message-era.js'
|
|
15
|
+
import { isWebAsk, prepareWebAsk } from './web-ask-admission.js'
|
|
16
|
+
import { maxGlobalMsgNumInDir } from '../routes/message-ref.js'
|
|
12
17
|
import { durableQueryJobsEnabled, messagesSteerEnabled, messagesTrailEnabled } from './query-job-feature.js'
|
|
13
18
|
import { claudeSupportsSteer } from './claude-cli-support.js'
|
|
14
19
|
import { steerProviderFor } from './claude-steer.js'
|
|
@@ -25,6 +30,9 @@ import {
|
|
|
25
30
|
import { mergeMediaAttachmentRefs } from '../../shared/media-attachment.js'
|
|
26
31
|
import { attachmentHistoryPrefix, defaultAttachmentRequest } from '../../shared/media-attachment.js'
|
|
27
32
|
import {
|
|
33
|
+
createSession,
|
|
34
|
+
getActiveSessions,
|
|
35
|
+
getSessionRaw,
|
|
28
36
|
findExchangesByJobIdentity,
|
|
29
37
|
flushConversationToDisk,
|
|
30
38
|
getSessionBinding,
|
|
@@ -44,7 +52,7 @@ import {
|
|
|
44
52
|
} from './query-job-types.js'
|
|
45
53
|
import { acquireMaintenanceWork } from './maintenance-lifecycle.js'
|
|
46
54
|
import type { JobTrailDraft } from './job-trail.js'
|
|
47
|
-
import { registerMessageReservationSource } from './message-reservations.js'
|
|
55
|
+
import { maxReservedGlobalMsgNum, registerMessageReservationSource } from './message-reservations.js'
|
|
48
56
|
import { bootResumeGate } from './boot-resume.js'
|
|
49
57
|
import { keyAnswerCompletionFields, keyAnswerDoneFields } from './key-answer-fields.js'
|
|
50
58
|
|
|
@@ -69,9 +77,36 @@ export class QueryJobAdmissionPreparationError extends Error {
|
|
|
69
77
|
* the strict QueryJobRequest parser before persistence. Once a reset era
|
|
70
78
|
* exists, reject pre-era clients before they stamp five-digit numbers into
|
|
71
79
|
* the fresh namespace. */
|
|
80
|
+
/** 6.99.10: the next-number ceiling for a web Ask, the same arithmetic /api/message-counter serves. */
|
|
81
|
+
function webAskCurrentMax(): number {
|
|
82
|
+
const era = currentMessageEra()
|
|
83
|
+
let liveMax = 0
|
|
84
|
+
for (const session of getActiveSessions()) {
|
|
85
|
+
for (const exchange of (session as { exchanges?: Array<{ globalMsgNum?: unknown; messageEra?: unknown }> }).exchanges ?? []) {
|
|
86
|
+
if (!exchangeBelongsToEra(exchange, era)) continue
|
|
87
|
+
if (typeof exchange?.globalMsgNum === 'number' && exchange.globalMsgNum > liveMax) liveMax = exchange.globalMsgNum
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return Math.max(liveMax, maxGlobalMsgNumInDir(dataPath('archive'), era), maxReservedGlobalMsgNum(era))
|
|
91
|
+
}
|
|
92
|
+
|
|
72
93
|
export async function preparePublicDurableQueryAdmission(raw: unknown): Promise<unknown> {
|
|
73
94
|
if (!raw || typeof raw !== 'object') return raw
|
|
74
|
-
|
|
95
|
+
let input = raw as Record<string, unknown>
|
|
96
|
+
// 6.99.10: Ask from COS Control Web (`source: 'web'`): the server picks the conversation and mints the message
|
|
97
|
+
// number (lib/web-ask-admission.ts). Every other caller is untouched.
|
|
98
|
+
if (isWebAsk(input) && !carriesBoundTo(input)) {
|
|
99
|
+
input = await prepareWebAsk(input, {
|
|
100
|
+
currentMax: webAskCurrentMax,
|
|
101
|
+
activeEra: currentMessageEra,
|
|
102
|
+
sessionExists: id => !!getSessionRaw(id),
|
|
103
|
+
createSession,
|
|
104
|
+
priorJob: async (clientJobId, generation) => {
|
|
105
|
+
const job = await queryJobCoordinator.getByClientGeneration(clientJobId, generation)
|
|
106
|
+
return job ? { sessionId: job.sessionId, globalMsgNum: job.globalMsgNum, messageEra: job.messageEra } : undefined
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
}
|
|
75
110
|
|
|
76
111
|
// Plan 4.2: an attached turn must NEVER silently degrade into an ordinary COS
|
|
77
112
|
// turn. The binding lives in the route path rather than the body, so a
|
|
@@ -255,7 +290,13 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
255
290
|
? { ...keyProfileLinkageProvider(keyTurn), resolvedModel: keyTurn.binding }
|
|
256
291
|
: { provider: providerFor(model), resolvedModel: model }
|
|
257
292
|
|
|
258
|
-
|
|
293
|
+
const runModel = request.maintenanceWorkflow === 'meeting-consolidation-v1'
|
|
294
|
+
? consolidationModelRunner(callModelStreaming, {
|
|
295
|
+
root: dataPath('meeting-consolidation'),
|
|
296
|
+
workspace: resolveProviderWorkDir({ scriptsDir: COS_SCRIPTS_DIR }),
|
|
297
|
+
runId: `${request.clientJobId}-g${request.generation}`,
|
|
298
|
+
}) : callModelStreaming
|
|
299
|
+
await runModel(
|
|
259
300
|
request.query,
|
|
260
301
|
request.sessionId,
|
|
261
302
|
{
|
|
@@ -134,6 +134,8 @@ export interface QueryJobRequest {
|
|
|
134
134
|
* it names the session, it does not change what is asked.
|
|
135
135
|
*/
|
|
136
136
|
sessionName?: string
|
|
137
|
+
/** Explicit user-started, checkpointed maintenance. Changes execution semantics. */
|
|
138
|
+
maintenanceWorkflow?: 'meeting-consolidation-v1'
|
|
137
139
|
/**
|
|
138
140
|
* Key lanes (QA U9 / C10): run THIS turn on the key profile it names without binding the
|
|
139
141
|
* session to it (the plan-limit "run this one on your key" offer). Kept only with a
|
|
@@ -599,6 +601,12 @@ export function parseQueryJobRequest(raw: unknown, options: ParseQueryJobRequest
|
|
|
599
601
|
const handoffCode = optionalString(input.handoffCode, 'handoff_code', 128)
|
|
600
602
|
const clientQueueItemId = optionalString(input.clientQueueItemId, 'client_queue_item_id', 120)
|
|
601
603
|
const sessionName = sanitizeSessionName(input.sessionName)
|
|
604
|
+
const maintenanceWorkflow = input.maintenanceWorkflow
|
|
605
|
+
if (maintenanceWorkflow != null && (maintenanceWorkflow !== 'meeting-consolidation-v1'
|
|
606
|
+
|| !['opus', 'codex-frontier'].includes(model ?? '') || input.dispatch != null || input.steerable === true
|
|
607
|
+
|| (Array.isArray(input.attachmentIds) && input.attachmentIds.length > 0))) {
|
|
608
|
+
throw new QueryJobValidationError('invalid_maintenance_workflow')
|
|
609
|
+
}
|
|
602
610
|
const globalMsgNum = input.globalMsgNum == null ? undefined : Number(input.globalMsgNum)
|
|
603
611
|
if (globalMsgNum != null && (!Number.isSafeInteger(globalMsgNum) || globalMsgNum < 1)) {
|
|
604
612
|
throw new QueryJobValidationError('invalid_global_msg_num')
|
|
@@ -670,6 +678,7 @@ export function parseQueryJobRequest(raw: unknown, options: ParseQueryJobRequest
|
|
|
670
678
|
...(input.handoffLatest === true ? { handoffLatest: true } : {}),
|
|
671
679
|
...(clientQueueItemId ? { clientQueueItemId } : {}),
|
|
672
680
|
...(sessionName ? { sessionName } : {}),
|
|
681
|
+
...(maintenanceWorkflow === 'meeting-consolidation-v1' ? { maintenanceWorkflow } as const : {}),
|
|
673
682
|
...(input.keyTurnOnly === true && isProfilePreference(model) ? { keyTurnOnly: true as const } : {}),
|
|
674
683
|
...(input.steerable === true ? { steerable: true as const } : {}),
|
|
675
684
|
attachmentIds,
|
|
@@ -735,7 +744,7 @@ export const FINGERPRINT_KEYS = [
|
|
|
735
744
|
'clientJobId', 'generation', 'query', 'sessionId', 'model', 'effort',
|
|
736
745
|
'cursorExecutionMode', 'messageEra', 'globalMsgNum', 'reference', 'handoffCode',
|
|
737
746
|
'handoffLatest', 'clientQueueItemId', 'attachmentIds', 'attachmentRefs',
|
|
738
|
-
'activityToolMode', 'keyTurnOnly',
|
|
747
|
+
'activityToolMode', 'keyTurnOnly', 'maintenanceWorkflow',
|
|
739
748
|
] as const satisfies readonly (keyof QueryJobRequest)[]
|
|
740
749
|
|
|
741
750
|
/** The request keys deliberately OUTSIDE the identity. Every key of
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop it P2 (plan PLAN_drop_it_2026-11-04 section 7): firing the reminders he set.
|
|
3
|
+
*
|
|
4
|
+
* - The time lives on the drop: `remind: { at, tz, firedAt, ackAt, snoozes }`.
|
|
5
|
+
* - A 30-second tick (the morning-brief scheduler pattern). After sleep or a server restart the first tick fires
|
|
6
|
+
* everything overdue, once each, and says how late it is when that is over 10 minutes.
|
|
7
|
+
* - `firedAt` is written under the drop store's writer lock BEFORE delivery, so a crash after the write can lose a
|
|
8
|
+
* banner but never repeat one, and two ticks can never both fire it.
|
|
9
|
+
* - The banner uses the notification id `reminder-<drop id>`. Control mirrors each pending reminder as a local
|
|
10
|
+
* calendar-trigger notification with the same id, so it fires with the server down and a later server fire
|
|
11
|
+
* replaces it instead of doubling it.
|
|
12
|
+
* - Actions: Done, Snooze 1 hour, Tomorrow 9. A snooze sets a new `at`, which is a new exactly-once key.
|
|
13
|
+
*
|
|
14
|
+
* A reminder he set fires at any hour (the one quiet-hours exception, per CLAUDE.md). No model, no script.
|
|
15
|
+
*/
|
|
16
|
+
import type { DropRecord } from './drop-store.js'
|
|
17
|
+
import { parseWhen, snoozeTargets, type WhenResult } from './drop-when.js'
|
|
18
|
+
import { clip, PING_TEXT, type PingCandidate } from './ping-sources.js'
|
|
19
|
+
|
|
20
|
+
export const REMINDER_TICK_MS = 30_000
|
|
21
|
+
/** Over this late, the reminder says how late it is (plan section 7). */
|
|
22
|
+
export const REMINDER_LATE_MS = 10 * 60_000
|
|
23
|
+
export const REMINDER_ACTIONS = [
|
|
24
|
+
{ id: 'reminder_done', label: 'Done' },
|
|
25
|
+
{ id: 'snooze_1h', label: 'Snooze 1 hour' }, // the id stays; the length is Settings, Reminders, snooze
|
|
26
|
+
{ id: 'snooze_tomorrow', label: 'Tomorrow 9' },
|
|
27
|
+
] as const
|
|
28
|
+
export type ReminderAction = typeof REMINDER_ACTIONS[number]['id']
|
|
29
|
+
|
|
30
|
+
/** The two store calls the scheduler needs; DropStore has both. */
|
|
31
|
+
export interface ReminderStore {
|
|
32
|
+
select(match: (row: Readonly<DropRecord>) => boolean): DropRecord[]
|
|
33
|
+
update(id: string, fn: (current: DropRecord) => DropRecord): DropRecord
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The notification id Control's local backup uses for the same reminder. */
|
|
37
|
+
export const reminderNotifyId = (dropId: string) => `reminder-${dropId}`
|
|
38
|
+
|
|
39
|
+
/** What the drop asked to be reminded about: its words without "remind me" and the time. */
|
|
40
|
+
export function reminderAbout(drop: Pick<DropRecord, 'text' | 'createdAt' | 'remind'>): string {
|
|
41
|
+
const tz = drop.remind?.tz || 'UTC'
|
|
42
|
+
const about = parseWhen(drop.text, Date.parse(drop.createdAt) || 0, tz).about
|
|
43
|
+
return clip(about || drop.text, PING_TEXT.title) || 'your reminder'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The ping's buttons, with the snooze named by its length ("Snooze 30 minutes"). */
|
|
47
|
+
export function reminderActions(snoozeMinutes = 60): Array<{ id: ReminderAction; label: string }> {
|
|
48
|
+
const len = snoozeMinutes % 60 === 0 ? `${snoozeMinutes / 60} hour${snoozeMinutes === 60 ? '' : 's'}` : `${snoozeMinutes} minutes`
|
|
49
|
+
return REMINDER_ACTIONS.map(a => ({ id: a.id, label: a.id === 'snooze_1h' ? `Snooze ${len}` : a.label }))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** "2 hours", "35 minutes", "1 day": how late a fire is. */
|
|
53
|
+
export function lateWords(ms: number): string {
|
|
54
|
+
const min = Math.round(ms / 60_000)
|
|
55
|
+
if (min < 60) return `${min} minute${min === 1 ? '' : 's'}`
|
|
56
|
+
const h = Math.round(min / 60)
|
|
57
|
+
if (h < 48) return `${h} hour${h === 1 ? '' : 's'}`
|
|
58
|
+
const d = Math.round(h / 24)
|
|
59
|
+
return `${d} day${d === 1 ? '' : 's'}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const pending = (r: Readonly<DropRecord>) => r.state !== 'undone' && r.dest === 'reminder' && !!r.remind?.at && !r.remind.firedAt && !r.remind.ackAt
|
|
63
|
+
|
|
64
|
+
/** Every reminder still to fire, soonest first: what Control mirrors as local notifications. */
|
|
65
|
+
export function pendingReminders(store: Pick<ReminderStore, 'select'>): Array<{ id: string; at: string; title: string; notifyId: string }> {
|
|
66
|
+
return store.select(pending)
|
|
67
|
+
.map(r => ({ id: r.id, at: r.remind!.at!, title: `Reminder: ${reminderAbout(r)}`, notifyId: reminderNotifyId(r.id) }))
|
|
68
|
+
.sort((a, b) => Date.parse(a.at) - Date.parse(b.at))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface FireResult { fired: number; late: number }
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fire every reminder whose time has come. `deliver` receives the candidates only after each `firedAt` is on disk.
|
|
75
|
+
* A store write that fails (store closed, disk full) skips that reminder this tick; it is tried again next tick.
|
|
76
|
+
*/
|
|
77
|
+
export function fireDueReminders(store: ReminderStore, nowMs: number, deliver: (c: PingCandidate[]) => void, options?: { snoozeMinutes?: number }): FireResult {
|
|
78
|
+
const due = store.select(r => pending(r) && Date.parse(r.remind!.at!) <= nowMs)
|
|
79
|
+
const out: PingCandidate[] = []
|
|
80
|
+
let late = 0
|
|
81
|
+
for (const drop of due) {
|
|
82
|
+
const at = drop.remind!.at!
|
|
83
|
+
let written: DropRecord
|
|
84
|
+
try {
|
|
85
|
+
written = store.update(drop.id, cur => {
|
|
86
|
+
// Re-checked under the writer lock: another tick, an ack or an undo may have landed since the select.
|
|
87
|
+
if (!pending(cur) || cur.remind!.at !== at) throw new Error('reminder_moved')
|
|
88
|
+
return { ...cur, remind: { ...cur.remind!, firedAt: new Date(nowMs).toISOString() } }
|
|
89
|
+
})
|
|
90
|
+
} catch { continue }
|
|
91
|
+
const lateMs = nowMs - Date.parse(at)
|
|
92
|
+
const about = reminderAbout(written)
|
|
93
|
+
const isLate = lateMs > REMINDER_LATE_MS
|
|
94
|
+
if (isLate) late++
|
|
95
|
+
out.push({
|
|
96
|
+
source: 'reminder', key: `reminder:${drop.id}:${at}`, title: 'Reminder',
|
|
97
|
+
line: isLate ? `Reminder (${lateWords(lateMs)} late): ${about}` : `Reminder: ${about}`,
|
|
98
|
+
links: [{ kind: 'reminder', id: drop.id, label: about }],
|
|
99
|
+
actions: reminderActions(options?.snoozeMinutes),
|
|
100
|
+
createdAt: nowMs, notifyId: reminderNotifyId(drop.id),
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
if (out.length) deliver(out)
|
|
104
|
+
return { fired: out.length, late }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Done, Snooze 1 hour or Tomorrow 9, on a reminder that has fired (or a pending one: Done also cancels it).
|
|
109
|
+
* Returns the updated drop, or null when it is not a live reminder.
|
|
110
|
+
*/
|
|
111
|
+
export function applyReminderAction(store: ReminderStore, dropId: string, action: ReminderAction, nowMs: number,
|
|
112
|
+
options?: { snoozeMs?: number; dayMinutes?: number }): DropRecord | null {
|
|
113
|
+
const [row] = store.select(r => r.id === dropId)
|
|
114
|
+
if (!row || row.state === 'undone' || row.dest !== 'reminder' || !row.remind || row.remind.ackAt) return null
|
|
115
|
+
const iso = (ms: number) => new Date(ms).toISOString()
|
|
116
|
+
const targets = snoozeTargets(nowMs, row.remind.tz || 'UTC', options)
|
|
117
|
+
try {
|
|
118
|
+
return store.update(dropId, cur => {
|
|
119
|
+
if (!cur.remind || cur.remind.ackAt) throw new Error('reminder_gone')
|
|
120
|
+
if (action === 'reminder_done') return { ...cur, remind: { ...cur.remind, ackAt: iso(nowMs) } }
|
|
121
|
+
const at = action === 'snooze_1h' ? targets.hour : targets.tomorrow9
|
|
122
|
+
return { ...cur, remind: { ...cur.remind, at: iso(at), firedAt: null, snoozes: cur.remind.snoozes + 1 } }
|
|
123
|
+
})
|
|
124
|
+
} catch { return null }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The reminder time read from a drop's words at filing, or why it could not be read (COS then asks once). */
|
|
128
|
+
export function reminderTimeFor(text: string, nowMs: number, timezone: string): WhenResult {
|
|
129
|
+
return parseWhen(text, nowMs, timezone)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The 30-second tick. `start` is idempotent; the timer never holds the process open. */
|
|
133
|
+
export function createReminderScheduler(deps: { store: ReminderStore; deliver: (c: PingCandidate[]) => void; now?: () => number; intervalMs?: number; snoozeMinutes?: () => number }) {
|
|
134
|
+
const now = deps.now ?? (() => Date.now())
|
|
135
|
+
let timer: ReturnType<typeof setInterval> | null = null
|
|
136
|
+
let running = false
|
|
137
|
+
const tick = (): FireResult => {
|
|
138
|
+
if (running) return { fired: 0, late: 0 }
|
|
139
|
+
running = true
|
|
140
|
+
try {
|
|
141
|
+
let snoozeMinutes: number | undefined
|
|
142
|
+
try { snoozeMinutes = deps.snoozeMinutes?.() } catch { snoozeMinutes = undefined }
|
|
143
|
+
const r = fireDueReminders(deps.store, now(), deps.deliver, { snoozeMinutes })
|
|
144
|
+
if (r.fired) console.log(`[reminders] fired=${r.fired} late=${r.late}`)
|
|
145
|
+
return r
|
|
146
|
+
} catch { return { fired: 0, late: 0 } } finally { running = false }
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
tick,
|
|
150
|
+
start() { if (timer) return; tick(); timer = setInterval(tick, deps.intervalMs ?? REMINDER_TICK_MS); timer.unref?.() },
|
|
151
|
+
stop() { if (timer) clearInterval(timer); timer = null },
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// This Mac's own Tailscale MagicDNS name, for COS Control Web's origin checks.
|
|
2
|
+
//
|
|
3
|
+
// WHY NOT THE TAILSCALE CLI. Under the server's LaunchAgent the `tailscale status --json` call
|
|
4
|
+
// the nudger makes does not answer (server.log: "trigger=boot dropped: Tailscale did not answer
|
|
5
|
+
// for 120s"), so the nudger never learns Self.DNSName and a phone at
|
|
6
|
+
// http://<mac>.<tailnet>.ts.net:3141/control was refused by CORS on every write (6.99.1).
|
|
7
|
+
// MagicDNS itself answers a reverse lookup of this Mac's own 100.x address at 100.100.100.100
|
|
8
|
+
// in about a millisecond, with no CLI involved. The nudger's answer still wins when it has one.
|
|
9
|
+
//
|
|
10
|
+
// EXACT NAME ONLY. The answer is normalised by normalizeTailscaleDnsName (lowercase, one
|
|
11
|
+
// trailing dot dropped, must end in .ts.net) and compared for equality; never a suffix match.
|
|
12
|
+
// No answer means no name: the tailnet NAME is refused and the tailnet IP URL keeps working.
|
|
13
|
+
|
|
14
|
+
import { Resolver } from 'node:dns/promises'
|
|
15
|
+
import { networkInterfaces } from 'node:os'
|
|
16
|
+
import { normalizeTailscaleDnsName, reachableIpv4Addresses } from './network-policy.js'
|
|
17
|
+
|
|
18
|
+
export const TAILNET_NAME_HIT_TTL_MS = 10 * 60_000
|
|
19
|
+
export const TAILNET_NAME_MISS_TTL_MS = 60_000
|
|
20
|
+
export const TAILNET_REVERSE_TIMEOUT_MS = 1_500
|
|
21
|
+
const MAGIC_DNS_RESOLVER = '100.100.100.100'
|
|
22
|
+
|
|
23
|
+
export interface TailnetSelfNameDeps {
|
|
24
|
+
/** The nudger's own reading, preferred whenever it has one. */
|
|
25
|
+
nudgerName?: () => string | null
|
|
26
|
+
/** This Mac's Tailscale IPv4, or null when Tailscale is not up. */
|
|
27
|
+
tailscaleIpv4?: () => string | null
|
|
28
|
+
/** PTR lookup of an address; names may carry a trailing dot. */
|
|
29
|
+
reverse?: (ip: string) => Promise<string[]>
|
|
30
|
+
now?: () => number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TailnetSelfName {
|
|
34
|
+
/** The name known right now, without waiting. */
|
|
35
|
+
currentSelfDnsName(): string | null
|
|
36
|
+
/** Look the name up (single-flight, cached); resolves to the name or null. Never throws. */
|
|
37
|
+
refresh(): Promise<string | null>
|
|
38
|
+
/** `SelfDnsSource` for isAllowedCorsOrigin: same as refresh(). */
|
|
39
|
+
queryStatus(): Promise<string | null>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function defaultTailscaleIpv4(): string | null {
|
|
43
|
+
return reachableIpv4Addresses(networkInterfaces()).find(a => a.kind === 'tailscale')?.address ?? null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function defaultReverse(ip: string): Promise<string[]> {
|
|
47
|
+
const resolver = new Resolver({ timeout: TAILNET_REVERSE_TIMEOUT_MS, tries: 1 })
|
|
48
|
+
resolver.setServers([MAGIC_DNS_RESOLVER])
|
|
49
|
+
return resolver.reverse(ip)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createTailnetSelfName(deps: TailnetSelfNameDeps = {}): TailnetSelfName {
|
|
53
|
+
const now = deps.now ?? (() => Date.now())
|
|
54
|
+
const ipv4 = deps.tailscaleIpv4 ?? defaultTailscaleIpv4
|
|
55
|
+
const reverse = deps.reverse ?? defaultReverse
|
|
56
|
+
let cached: { name: string | null; at: number } | null = null
|
|
57
|
+
let inflight: Promise<string | null> | null = null
|
|
58
|
+
|
|
59
|
+
const fromNudger = (): string | null => {
|
|
60
|
+
try { return normalizeTailscaleDnsName(deps.nudgerName?.() ?? null) } catch { return null }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const currentSelfDnsName = (): string | null => fromNudger() ?? cached?.name ?? null
|
|
64
|
+
|
|
65
|
+
const refresh = (): Promise<string | null> => {
|
|
66
|
+
const known = fromNudger()
|
|
67
|
+
if (known) return Promise.resolve(known)
|
|
68
|
+
const at = now()
|
|
69
|
+
if (cached && at - cached.at < (cached.name ? TAILNET_NAME_HIT_TTL_MS : TAILNET_NAME_MISS_TTL_MS)) {
|
|
70
|
+
return Promise.resolve(cached.name)
|
|
71
|
+
}
|
|
72
|
+
if (inflight) return inflight
|
|
73
|
+
inflight = (async () => {
|
|
74
|
+
let name: string | null = null
|
|
75
|
+
try {
|
|
76
|
+
const ip = ipv4()
|
|
77
|
+
if (ip) {
|
|
78
|
+
const names = await reverse(ip)
|
|
79
|
+
name = names.map(n => normalizeTailscaleDnsName(n)).find((n): n is string => n !== null) ?? null
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
name = null
|
|
83
|
+
}
|
|
84
|
+
// A miss never erases a name we already had: a blip in MagicDNS must not lock the phone out.
|
|
85
|
+
cached = { name: name ?? cached?.name ?? null, at: now() }
|
|
86
|
+
return cached.name
|
|
87
|
+
})().finally(() => { inflight = null })
|
|
88
|
+
return inflight
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { currentSelfDnsName, refresh, queryStatus: refresh }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let singleton: TailnetSelfName | null = null
|
|
95
|
+
/** index.ts installs the server's instance, wired to its nudger. */
|
|
96
|
+
export function installTailnetSelfName(deps: TailnetSelfNameDeps): TailnetSelfName {
|
|
97
|
+
singleton = createTailnetSelfName(deps)
|
|
98
|
+
return singleton
|
|
99
|
+
}
|
|
100
|
+
export function getTailnetSelfName(): TailnetSelfName {
|
|
101
|
+
if (!singleton) singleton = createTailnetSelfName()
|
|
102
|
+
return singleton
|
|
103
|
+
}
|
|
@@ -39,7 +39,7 @@ import { execFile } from 'node:child_process'
|
|
|
39
39
|
import { accessSync, constants as fsConstants } from 'node:fs'
|
|
40
40
|
import { networkInterfaces } from 'node:os'
|
|
41
41
|
import { readSwitch } from './env-switch.js'
|
|
42
|
-
import { isTailscaleIpv4, normalizeRemoteIp } from './network-policy.js'
|
|
42
|
+
import { isTailscaleIpv4, normalizeRemoteIp, normalizeTailscaleDnsName } from './network-policy.js'
|
|
43
43
|
|
|
44
44
|
export const TAILSCALE_NUDGE_LIMITS = Object.freeze({
|
|
45
45
|
tickMs: 10_000,
|
|
@@ -135,6 +135,8 @@ export interface PeerStatus {
|
|
|
135
135
|
export interface TailscaleStatus {
|
|
136
136
|
backendState: string
|
|
137
137
|
selfIps: string[]
|
|
138
|
+
selfDnsName?: string | null
|
|
139
|
+
certDomains?: string[]
|
|
138
140
|
peers: PeerStatus[]
|
|
139
141
|
at: number
|
|
140
142
|
}
|
|
@@ -145,6 +147,10 @@ export function parseTailscaleStatus(text: string, at: number): TailscaleStatus
|
|
|
145
147
|
try { raw = JSON.parse(text) } catch { return null }
|
|
146
148
|
if (!raw || typeof raw !== 'object' || typeof raw.BackendState !== 'string') return null
|
|
147
149
|
const v4 = (ips: unknown): string[] => Array.isArray(ips) ? ips.filter((ip): ip is string => typeof ip === 'string' && isTailscaleIpv4(ip)) : []
|
|
150
|
+
const selfDnsName = normalizeTailscaleDnsName(raw.Self?.DNSName)
|
|
151
|
+
const certDomains = Array.isArray(raw.CertDomains)
|
|
152
|
+
? (raw.CertDomains as unknown[]).filter((d): d is string => typeof d === 'string' && d.trim().length > 0).map((d: string) => d.trim().toLowerCase().replace(/\.$/, ''))
|
|
153
|
+
: []
|
|
148
154
|
const peers: PeerStatus[] = []
|
|
149
155
|
const peerMap = raw.Peer && typeof raw.Peer === 'object' ? raw.Peer : {}
|
|
150
156
|
for (const p of Object.values(peerMap) as any[]) {
|
|
@@ -160,7 +166,7 @@ export function parseTailscaleStatus(text: string, at: number): TailscaleStatus
|
|
|
160
166
|
lastHandshakeMs: Number.isFinite(hs) && hs > 0 ? hs : 0,
|
|
161
167
|
})
|
|
162
168
|
}
|
|
163
|
-
return { backendState: raw.BackendState, selfIps: v4(raw.Self?.TailscaleIPs).sort(), peers, at }
|
|
169
|
+
return { backendState: raw.BackendState, selfIps: v4(raw.Self?.TailscaleIPs).sort(), selfDnsName, certDomains, peers, at }
|
|
164
170
|
}
|
|
165
171
|
|
|
166
172
|
/** Disco ping output: "pong from <name> (<ip>) via <DERP(x) | ip:port | peer-relay(...)> in 91ms". */
|
|
@@ -419,6 +425,38 @@ export class TailscaleNudger {
|
|
|
419
425
|
return read
|
|
420
426
|
}
|
|
421
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Cached or last-known Tailscale status, plus a single-flight on-demand refresh.
|
|
430
|
+
* Preserves the last known-good `selfDnsName` across transient read failures so CORS
|
|
431
|
+
* never tears or fails open under concurrent requests.
|
|
432
|
+
*/
|
|
433
|
+
private statusRefreshInFlight: Promise<TailscaleStatus | null> | null = null
|
|
434
|
+
private lastKnownSelfDnsName: string | null = null
|
|
435
|
+
|
|
436
|
+
currentSelfDnsName(): string | null {
|
|
437
|
+
if (this.status?.selfDnsName) {
|
|
438
|
+
this.lastKnownSelfDnsName = this.status.selfDnsName
|
|
439
|
+
}
|
|
440
|
+
return this.lastKnownSelfDnsName
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async queryStatus(forceRefresh = false): Promise<TailscaleStatus | null> {
|
|
444
|
+
const at = this.now()
|
|
445
|
+
if (!forceRefresh && this.status && at - this.status.at <= TAILSCALE_NUDGE_LIMITS.statusFreshMs) {
|
|
446
|
+
if (this.status.selfDnsName) this.lastKnownSelfDnsName = this.status.selfDnsName
|
|
447
|
+
return this.status
|
|
448
|
+
}
|
|
449
|
+
if (this.statusRefreshInFlight) return this.statusRefreshInFlight
|
|
450
|
+
if (!this.ensureBin(at)) return this.status
|
|
451
|
+
this.statusRefreshInFlight = this.readStatus(at).then(parsed => {
|
|
452
|
+
if (parsed?.selfDnsName) this.lastKnownSelfDnsName = parsed.selfDnsName
|
|
453
|
+
return parsed ?? this.status
|
|
454
|
+
}).finally(() => {
|
|
455
|
+
this.statusRefreshInFlight = null
|
|
456
|
+
})
|
|
457
|
+
return this.statusRefreshInFlight
|
|
458
|
+
}
|
|
459
|
+
|
|
422
460
|
private async runEvent(pending: PendingEvent, at: number): Promise<void> {
|
|
423
461
|
const L = TAILSCALE_NUDGE_LIMITS
|
|
424
462
|
const status = await this.freshStatus(at)
|
package/server/lib/task-store.ts
CHANGED
|
@@ -853,3 +853,35 @@ export function liveLedgerFor(taskId: string, day: string, paths = taskStorePath
|
|
|
853
853
|
run.taskId === taskId && run.day === day && (run.status === 'dispatching' || run.status === 'running'),
|
|
854
854
|
)
|
|
855
855
|
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Drop it (plan section 3): a new Inbox card from a drop, in one locked bridge write that answers the card's id
|
|
859
|
+
* (`task-capture-drop`, stdin `{ text, dropId }`). `captureTask` answers no id, and Undo needs one. Repeating the
|
|
860
|
+
* same dropId answers the same card (`created: false`). A bridge without the verb answers
|
|
861
|
+
* `cos_pipeline_not_configured`, which the caller turns into a plain note.
|
|
862
|
+
*/
|
|
863
|
+
export interface CapturedDropCard { id: string; workIdentity: string; created: boolean }
|
|
864
|
+
export async function captureDropCard(domain: string, text: string, dropId: string): Promise<CapturedDropCard> {
|
|
865
|
+
if (!isSafeDomainName(domain) || !taskDomains().includes(domain)) throw new TaskRunError(400, 'invalid_domain', 'Unknown task domain')
|
|
866
|
+
const clean = text.replace(/\s+/g, ' ').trim()
|
|
867
|
+
if (!clean || clean.length > 2000) throw new TaskRunError(422, 'invalid_task_text', 'Card text must be 1 to 2,000 characters')
|
|
868
|
+
rejectReservedWorkText(clean)
|
|
869
|
+
const out = await withLockRetry(() => bridge(['task-capture-drop', domain], JSON.stringify({ text: clean, dropId }))) as Partial<CapturedDropCard> & { ok?: unknown }
|
|
870
|
+
if (out?.ok !== true || typeof out.id !== 'string' || !/^[a-f0-9]{12}$/.test(out.id) || typeof out.created !== 'boolean'
|
|
871
|
+
|| (out.workIdentity !== undefined && (typeof out.workIdentity !== 'string' || !/^[a-f0-9]{12}$/.test(out.workIdentity)))) {
|
|
872
|
+
throw new TaskRunError(502, 'capture_unverified', 'The card could not be confirmed. Refresh Work before trying again.')
|
|
873
|
+
}
|
|
874
|
+
return { id: out.id, workIdentity: out.workIdentity ?? out.id, created: out.created }
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Undo for captureDropCard: archive (`[~]`) the card only while it is untouched (`task-archive-drop`, stdin
|
|
879
|
+
* `{ dropId, expectedText }`). `touched` means it was edited, checked or moved since: Undo refuses with that reason.
|
|
880
|
+
* `gone` means the card is no longer there, which Undo treats as done.
|
|
881
|
+
*/
|
|
882
|
+
export async function archiveDropCard(domain: string, id: string, dropId: string, expectedText: string): Promise<'archived' | 'touched' | 'gone'> {
|
|
883
|
+
if (!isSafeDomainName(domain) || !/^[a-f0-9]{12}$/.test(id)) throw new TaskRunError(422, 'invalid_task_id', 'Use the exact card id')
|
|
884
|
+
const out = await withLockRetry(() => bridge(['task-archive-drop', domain, id], JSON.stringify({ dropId, expectedText: expectedText.replace(/\s+/g, ' ').trim() }))) as { ok?: unknown; result?: unknown }
|
|
885
|
+
if (out?.ok === true && (out.result === 'archived' || out.result === 'touched' || out.result === 'gone')) return out.result
|
|
886
|
+
throw new TaskRunError(502, 'archive_unverified', 'The card could not be confirmed. Refresh Work before trying again.')
|
|
887
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.99.10 (Drop it plan section 12): Ask from COS Control Web goes through POST /api/query-jobs, the route the glasses
|
|
3
|
+
* use, with `source: 'web'`. The browser holds no message counter and no conversation, so the server fills in what the
|
|
4
|
+
* phone would send:
|
|
5
|
+
*
|
|
6
|
+
* - `sessionId`: kept when it names a live conversation (a follow-up), else a new conversation is made, as the morning
|
|
7
|
+
* brief does. The answer projects into that conversation, so it reads in Messages (Recent on the Mac and the web)
|
|
8
|
+
* and in the lens message list like any other turn.
|
|
9
|
+
* - `globalMsgNum`: the next message number, minted from the same ceiling /api/message-counter serves (live sessions,
|
|
10
|
+
* the archive, and every number a not-yet-projected job holds), and never below a number this process minted
|
|
11
|
+
* already, so two web asks at once take two numbers. One web Ask moves the counter by exactly one.
|
|
12
|
+
* - `messageEra`: the active era.
|
|
13
|
+
*
|
|
14
|
+
* A retry of the same clientJobId and generation (a lost 202) reuses the first admission's session and number, so it
|
|
15
|
+
* replays instead of conflicting. `source` itself is consumed here; the job journal does not store it.
|
|
16
|
+
*
|
|
17
|
+
* Any other source value, or none, is left exactly as it was: the glasses and Control paths are unchanged.
|
|
18
|
+
*/
|
|
19
|
+
export interface WebAskAdmissionDeps {
|
|
20
|
+
/** Highest message number in use in the active era (live, archive, reservations). */
|
|
21
|
+
currentMax: () => number
|
|
22
|
+
activeEra: () => string
|
|
23
|
+
sessionExists: (sessionId: string) => boolean
|
|
24
|
+
createSession: () => string
|
|
25
|
+
/** The admitted job for this client identity, if any (lost-202 replay). */
|
|
26
|
+
priorJob: (clientJobId: string, generation: number) => Promise<{ sessionId?: string; globalMsgNum?: number; messageEra?: string } | undefined>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const WEB_ASK_SOURCE = 'web'
|
|
30
|
+
let lastMinted = 0
|
|
31
|
+
|
|
32
|
+
/** Test-only: forget the in-process floor. */
|
|
33
|
+
export function resetWebAskMintForTests(): void { lastMinted = 0 }
|
|
34
|
+
|
|
35
|
+
export function isWebAsk(input: Record<string, unknown>): boolean {
|
|
36
|
+
return input.source === WEB_ASK_SOURCE
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Returns the request with `source` removed and the web fields filled. Synchronous after the replay lookup, so the
|
|
40
|
+
* mint and the floor update happen together. */
|
|
41
|
+
export async function prepareWebAsk(input: Record<string, unknown>, deps: WebAskAdmissionDeps): Promise<Record<string, unknown>> {
|
|
42
|
+
const { source: _source, ...rest } = input
|
|
43
|
+
const clientJobId = typeof rest.clientJobId === 'string' ? rest.clientJobId.toLowerCase() : ''
|
|
44
|
+
const generation = Number(rest.generation)
|
|
45
|
+
const prior = clientJobId && Number.isSafeInteger(generation) && generation >= 1
|
|
46
|
+
? await deps.priorJob(clientJobId, generation).catch(() => undefined)
|
|
47
|
+
: undefined
|
|
48
|
+
if (prior?.sessionId && prior.globalMsgNum) {
|
|
49
|
+
return { ...rest, sessionId: prior.sessionId, globalMsgNum: prior.globalMsgNum, messageEra: prior.messageEra ?? deps.activeEra() }
|
|
50
|
+
}
|
|
51
|
+
const asked = typeof rest.sessionId === 'string' ? rest.sessionId : ''
|
|
52
|
+
const sessionId = asked && deps.sessionExists(asked) ? asked : deps.createSession()
|
|
53
|
+
const globalMsgNum = Math.max(deps.currentMax(), lastMinted) + 1
|
|
54
|
+
lastMinted = globalMsgNum
|
|
55
|
+
return { ...rest, sessionId, globalMsgNum, messageEra: deps.activeEra() }
|
|
56
|
+
}
|