@gotcos/glasses-server 6.44.1 → 6.44.3
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 +17 -0
- package/package.json +1 -1
- package/server/lib/domain-label.ts +13 -0
- package/server/lib/domains.ts +39 -0
- package/server/lib/task-dispatcher.ts +2 -2
- package/server/lib/task-store.ts +43 -5
- package/server/routes/tasks.ts +64 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
## 6.44.3
|
|
2
|
+
|
|
3
|
+
A task row carries enough to show and edit it.
|
|
4
|
+
|
|
5
|
+
- `GET /api/tasks` rows gain `text` (the whole description), `source`, `checked`, and `owner`/`agentState`/`agentNo` when present. `title` still exists and is still capped at 44 characters, which is a G2 lens row budget: applying it to every surface is why COS Control rendered "Send James/John the call recording + recap…" in a window 1900px wide. Additive on purpose so clients through 6.9.451, which read `title`, keep working.
|
|
6
|
+
- `PUT /api/domains` sets the user's domain list, so a settings pane can write it instead of the user hand-editing `~/.cos-glasses/.cos-profile.json`. It replaces the configured list wholesale and validates each name for path safety; discovered folders are never written, so removing a name from config cannot hide a folder that still holds tasks.
|
|
7
|
+
- `PATCH /api/tasks/:id` accepts `text`, rewriting a task's words through a new `task-set-text` bridge subcommand. The source block, the `[run ...]` schedule, and any agent marker are rebuilt from parsed state, so editing the words never drops provenance. A task with a running agent is refused, since rewriting the line would change the task the agent was dispatched against.
|
|
8
|
+
|
|
9
|
+
## 6.44.2
|
|
10
|
+
|
|
11
|
+
Task domains come from the user, not from the build.
|
|
12
|
+
|
|
13
|
+
- `FULL_DOMAINS` was `['quilt','personal','hermit_crabs','sprocket_rocket']` — one user's business units, compiled into everyone's copy. The task store and dispatcher now resolve domains through `taskDomainNames`, a union of the profile's `domains` and every directory under `operations/` holding a `tasks.md`. On an install with no configured domains this returns exactly the folders already there, so nothing changes for an existing setup; a fresh COS gets `personal` and `business`.
|
|
14
|
+
- Task discovery keys on `tasks.md`, deliberately NOT on the `meetings/YYYY-MM` tree that `discoveredDomains` requires. Measured here: `uncategorized/` has a months tree and no `tasks.md`, so reusing meetings discovery would have offered a task domain whose file does not exist, and a domain with tasks but no meetings yet would have been invisible.
|
|
15
|
+
- Capture now rejects an unknown domain with the name it was given instead of "full domain required".
|
|
16
|
+
- New `GET /api/domains` returns `{ domains: [{ name, abbr, label }], gate }` so no client hardcodes a picker. `abbr` is derived server-side, ending the two disagreeing abbreviation maps; `label` is derived from the key and leaves a name the user wrote as prose ("DNP study") untouched.
|
|
17
|
+
|
|
1
18
|
## 6.44.1
|
|
2
19
|
|
|
3
20
|
Board and lens titles are readable.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.44.
|
|
3
|
+
"version": "6.44.3",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Human label for a domain key.
|
|
2
|
+
*
|
|
3
|
+
* `hermit_crabs` should read "Hermit Crabs" in a picker, but the label must be
|
|
4
|
+
* DERIVED, never a lookup table: a table is how one user's four business units
|
|
5
|
+
* ended up compiled into everyone's build. A user-supplied domain that is
|
|
6
|
+
* already prose ("DNP study") is returned as-is apart from its separators. */
|
|
7
|
+
export function domainLabel(name: string): string {
|
|
8
|
+
const words = name.split(/[_-]+/).filter(Boolean)
|
|
9
|
+
if (words.length === 0) return name
|
|
10
|
+
// Only lowercase words are title-cased. A name the user already capitalised
|
|
11
|
+
// ("DNP study", "iOS") keeps their capitalisation.
|
|
12
|
+
return words.map(w => (w === w.toLowerCase() ? w.charAt(0).toUpperCase() + w.slice(1) : w)).join(' ')
|
|
13
|
+
}
|
package/server/lib/domains.ts
CHANGED
|
@@ -134,6 +134,45 @@ export function discoveredDomains(operationsDir: string | null): string[] {
|
|
|
134
134
|
}).sort()
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Immediate subdirectories of `operationsDir` holding a `tasks.md`.
|
|
139
|
+
*
|
|
140
|
+
* A SEPARATE signal from `discoveredDomains`, deliberately. That one requires a
|
|
141
|
+
* `meetings/YYYY-MM` tree, which is the right shape for the meeting lister and
|
|
142
|
+
* the wrong shape here: measured on this install, `uncategorized/` has a months
|
|
143
|
+
* tree and no `tasks.md`, so reusing meetings discovery would offer a task
|
|
144
|
+
* domain whose file does not exist, while a domain with tasks and no meetings
|
|
145
|
+
* yet would be invisible. The task store reads `tasks.md`, so that is the file
|
|
146
|
+
* that decides.
|
|
147
|
+
*/
|
|
148
|
+
export function discoveredTaskDomains(operationsDir: string | null): string[] {
|
|
149
|
+
if (!operationsDir || !existsSync(operationsDir)) return []
|
|
150
|
+
let names: string[]
|
|
151
|
+
try {
|
|
152
|
+
names = readdirSync(operationsDir, { withFileTypes: true })
|
|
153
|
+
.filter(e => e.isDirectory()).map(e => e.name)
|
|
154
|
+
} catch { return [] }
|
|
155
|
+
return names.filter(isSafeDomainName).filter(name => {
|
|
156
|
+
try { return statSync(join(operationsDir, name, 'tasks.md')).isFile() } catch { return false }
|
|
157
|
+
}).sort()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Task domains: configured first, then any directory with a `tasks.md`.
|
|
162
|
+
*
|
|
163
|
+
* Same union rule as `resolveDomains` and for the same reason — Miles has no
|
|
164
|
+
* `domains` in his profile, discovery finds his four, and the union is exactly
|
|
165
|
+
* his four, so switching the task store onto this changes nothing about his
|
|
166
|
+
* install while removing one user's business units from everyone else's build.
|
|
167
|
+
*/
|
|
168
|
+
export function taskDomainNames(operationsDir: string | null): string[] {
|
|
169
|
+
const configured = configuredDomains().map(d => d.name)
|
|
170
|
+
const discovered = discoveredTaskDomains(operationsDir)
|
|
171
|
+
if (configured.length === 0 && discovered.length === 0) return [...DEFAULT_DOMAINS]
|
|
172
|
+
const known = new Set(configured.map(n => n.toLowerCase()))
|
|
173
|
+
return [...configured, ...discovered.filter(n => !known.has(n.toLowerCase()))]
|
|
174
|
+
}
|
|
175
|
+
|
|
137
176
|
/**
|
|
138
177
|
* Every domain this COS has, as a UNION of configuration and what is on disk.
|
|
139
178
|
*
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
type QueryJobSnapshot,
|
|
30
30
|
} from './query-job-types.js'
|
|
31
31
|
import {
|
|
32
|
-
|
|
32
|
+
taskDomains,
|
|
33
33
|
TASK_DISPATCH_LIMITS,
|
|
34
34
|
TASK_DISPATCH_WALL_MS,
|
|
35
35
|
TASK_JOB_LOST_MS,
|
|
@@ -597,7 +597,7 @@ export async function reconcileDispatch(injected?: TaskDispatcherDeps): Promise<
|
|
|
597
597
|
}
|
|
598
598
|
const timer = setTimeout(() => lease?.release(), TASK_LEASE_CEILING_MS)
|
|
599
599
|
try {
|
|
600
|
-
const groups = await Promise.all(
|
|
600
|
+
const groups = await Promise.all(taskDomains().map(domain => (deps.loadRows ? deps.loadRows(clock.day) : loadDomainRows(domain, clock.day))))
|
|
601
601
|
const rows = groups.flat()
|
|
602
602
|
const ledger = loadTaskLedger(pathsOf(deps))
|
|
603
603
|
const live = ledger.filter(run => run.status === 'dispatching' || run.status === 'running')
|
package/server/lib/task-store.ts
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
} from './morning-brief-config.js'
|
|
11
11
|
import { localClock, shiftDay, taskInstant } from './morning-brief-schedule.js'
|
|
12
12
|
import { callPython, pythonBridgeAvailable } from './python-bridge.js'
|
|
13
|
+
import { resolveCosOperationsDir } from './cos-operations-meetings.js'
|
|
14
|
+
import { taskDomainNames } from './domains.js'
|
|
13
15
|
import { isClientJobId } from './query-job-types.js'
|
|
14
16
|
import { DEFAULT_MODEL, isClaudeModel } from '../../shared/model-preference.js'
|
|
15
17
|
|
|
@@ -30,8 +32,16 @@ export const TASK_BRIDGE_TIMEOUT_MS = 12_000
|
|
|
30
32
|
export const TASK_JOB_LOST_MS = 6 * 60 * 60_000
|
|
31
33
|
export const TASK_LOCK_RETRY = Object.freeze({ attempts: 3, spacingMs: 300 })
|
|
32
34
|
export const TASK_TODAY_PURGE_HORIZON_DAYS = 7
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
/** Domains come from the user's own config and their own `operations/` tree, not
|
|
36
|
+
* from a list baked into the build. The previous constant here was one user's
|
|
37
|
+
* four business units, which is why a second COS install could not name
|
|
38
|
+
* anything that worked. `taskDomainNames` unions configured domains with every
|
|
39
|
+
* directory holding a `tasks.md`. */
|
|
40
|
+
export function taskDomains(): string[] {
|
|
41
|
+
return taskDomainNames(resolveCosOperationsDir())
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type TaskDomain = string
|
|
35
45
|
export type TaskRunStatus = 'dispatching' | 'running' | 'done' | 'superseded' | 'failed' | 'orphaned'
|
|
36
46
|
export const TASK_RUN_TERMINAL = new Set<TaskRunStatus>(['done', 'superseded', 'failed', 'orphaned'])
|
|
37
47
|
export type TaskColumn = 'done' | 'running' | 'today' | 'carried' | 'scheduled' | 'inbox'
|
|
@@ -106,7 +116,16 @@ export interface TaskBoardRow {
|
|
|
106
116
|
id: string
|
|
107
117
|
ref: string
|
|
108
118
|
domain: string
|
|
119
|
+
/** Capped to TASK_TITLE_MAX for a lens row. */
|
|
109
120
|
title: string
|
|
121
|
+
/** The whole description, for surfaces with room. */
|
|
122
|
+
text: string
|
|
123
|
+
source?: string
|
|
124
|
+
owner?: string
|
|
125
|
+
delegated?: boolean
|
|
126
|
+
agentState?: 'running' | 'done' | 'failed'
|
|
127
|
+
agentNo?: number
|
|
128
|
+
checked: boolean
|
|
110
129
|
column: TaskColumn
|
|
111
130
|
priority: string
|
|
112
131
|
runAt?: string
|
|
@@ -329,7 +348,7 @@ export async function loadDomainRows(domain: TaskDomain, day: string): Promise<B
|
|
|
329
348
|
}
|
|
330
349
|
|
|
331
350
|
export async function loadAllRows(day: string): Promise<BridgeTaskRow[]> {
|
|
332
|
-
const groups = await Promise.all(
|
|
351
|
+
const groups = await Promise.all(taskDomains().map(domain => loadDomainRows(domain, day)))
|
|
333
352
|
return groups.flat()
|
|
334
353
|
}
|
|
335
354
|
|
|
@@ -379,6 +398,17 @@ export function projectRow(
|
|
|
379
398
|
ref: row.ref,
|
|
380
399
|
domain: row.domain,
|
|
381
400
|
title: taskTitle(row.description),
|
|
401
|
+
// `title` stays capped for the lens; `text` is the whole line. The cap is a
|
|
402
|
+
// G2 row budget and was being applied to every surface, so a Mac window
|
|
403
|
+
// 1900px wide showed "Send James/John the call recording + recap…". Additive
|
|
404
|
+
// on purpose: clients through 6.9.451 read `title` and must keep working.
|
|
405
|
+
text: row.description,
|
|
406
|
+
...(row.source ? { source: row.source } : {}),
|
|
407
|
+
...(row.owner ? { owner: row.owner } : {}),
|
|
408
|
+
...(row.delegated ? { delegated: true } : {}),
|
|
409
|
+
...(row.agent_state ? { agentState: row.agent_state } : {}),
|
|
410
|
+
...(row.agent_no != null ? { agentNo: row.agent_no } : {}),
|
|
411
|
+
checked: row.is_checked,
|
|
382
412
|
column: col,
|
|
383
413
|
priority: row.priority,
|
|
384
414
|
...(runAt ? { runAt: new Date(taskInstant(runAt.day, runAt.minutes, tz)).toISOString() } : {}),
|
|
@@ -420,8 +450,8 @@ export async function captureTask(body: {
|
|
|
420
450
|
if (!pythonBridgeAvailable()) throw new TaskRunError(503, 'cos_pipeline_not_configured', 'COS pipeline is not configured.')
|
|
421
451
|
if (!body.captureId) throw new TaskRunError(400, 'capture_id_required', 'captureId is required.')
|
|
422
452
|
if (!isClientJobId(body.captureId)) throw new TaskRunError(422, 'invalid_capture_id', 'captureId must be a UUID v4.')
|
|
423
|
-
if (!
|
|
424
|
-
throw new TaskRunError(400, 'invalid_domain',
|
|
453
|
+
if (!taskDomains().includes(body.domain)) {
|
|
454
|
+
throw new TaskRunError(400, 'invalid_domain', `unknown domain: ${body.domain}`)
|
|
425
455
|
}
|
|
426
456
|
const paths = taskStorePaths()
|
|
427
457
|
const seen = loadCapturesSeen(paths)
|
|
@@ -448,6 +478,14 @@ export async function setTaskRunAt(domain: string, id: string, runAt: string | n
|
|
|
448
478
|
await withLockRetry(() => bridge(args))
|
|
449
479
|
}
|
|
450
480
|
|
|
481
|
+
/** Rewrite a task's words. The bridge preserves its source block and schedule. */
|
|
482
|
+
export async function setTaskText(domain: string, id: string, text: string): Promise<void> {
|
|
483
|
+
const clean = text.replace(/\s+/g, ' ').trim()
|
|
484
|
+
if (!clean) throw new TaskRunError(400, 'text_required', 'text is required')
|
|
485
|
+
if (clean.length > 2000) throw new TaskRunError(422, 'text_too_long', 'text must be 2000 characters or fewer')
|
|
486
|
+
await withLockRetry(() => bridge(['task-set-text', domain, id], clean))
|
|
487
|
+
}
|
|
488
|
+
|
|
451
489
|
export async function moveTask(domain: string, id: string, section: string, nowMs = Date.now()): Promise<void> {
|
|
452
490
|
const { clock } = briefContext(nowMs)
|
|
453
491
|
const args = ['task-move', domain, id, '--section', section]
|
package/server/routes/tasks.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { Router } from 'express'
|
|
2
2
|
import { queryJobCoordinator } from '../lib/query-job-runtime.js'
|
|
3
3
|
import { runTaskNow } from '../lib/task-dispatcher.js'
|
|
4
|
+
import { domainAbbreviation, isSafeDomainName } from '../lib/domains.js'
|
|
5
|
+
import { updateProfileFields } from '../lib/profile.js'
|
|
6
|
+
import { domainLabel } from '../lib/domain-label.js'
|
|
4
7
|
import {
|
|
5
8
|
TaskBridgeError,
|
|
6
9
|
TaskRunError,
|
|
@@ -12,6 +15,8 @@ import {
|
|
|
12
15
|
moveTask,
|
|
13
16
|
saveDispatchCap,
|
|
14
17
|
setTaskRunAt,
|
|
18
|
+
setTaskText,
|
|
19
|
+
taskDomains,
|
|
15
20
|
tasksGate,
|
|
16
21
|
workBadgeCount,
|
|
17
22
|
} from '../lib/task-store.js'
|
|
@@ -109,6 +114,61 @@ tasksRouter.patch('/tasks/dispatch-cap', (req, res) => {
|
|
|
109
114
|
}
|
|
110
115
|
})
|
|
111
116
|
|
|
117
|
+
/** The domain list a client should offer, so no picker hardcodes one user's
|
|
118
|
+
* business units. `abbr` is derived server-side so every surface agrees on the
|
|
119
|
+
* badge; the two abbreviation maps that used to live in the app disagreed. */
|
|
120
|
+
tasksRouter.get('/domains', (_req, res) => {
|
|
121
|
+
try {
|
|
122
|
+
const names = taskDomains()
|
|
123
|
+
res.json({
|
|
124
|
+
domains: names.map(name => ({ name, abbr: domainAbbreviation(name), label: domainLabel(name) })),
|
|
125
|
+
gate: tasksGate(),
|
|
126
|
+
})
|
|
127
|
+
} catch (error) {
|
|
128
|
+
sendError(res, error)
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
/** Set the user's domain list. Replaces it wholesale: the client shows the
|
|
133
|
+
* current list and sends back what it wants, which is what a settings pane does.
|
|
134
|
+
* Discovered folders are NOT written here — they keep showing because the read
|
|
135
|
+
* side unions them in, so removing a name from config never hides a folder that
|
|
136
|
+
* still holds tasks. */
|
|
137
|
+
tasksRouter.put('/domains', (req, res) => {
|
|
138
|
+
try {
|
|
139
|
+
const raw = (req.body ?? {}).domains
|
|
140
|
+
if (!Array.isArray(raw)) throw new TaskRunError(400, 'domains_required', 'domains must be an array')
|
|
141
|
+
if (raw.length > 32) throw new TaskRunError(422, 'too_many_domains', 'at most 32 domains')
|
|
142
|
+
const names: string[] = []
|
|
143
|
+
const seen = new Set<string>()
|
|
144
|
+
for (const entry of raw) {
|
|
145
|
+
const name = typeof entry === 'string'
|
|
146
|
+
? entry
|
|
147
|
+
: (entry && typeof entry === 'object' && typeof (entry as { name?: unknown }).name === 'string')
|
|
148
|
+
? (entry as { name: string }).name
|
|
149
|
+
: ''
|
|
150
|
+
const trimmed = name.trim()
|
|
151
|
+
if (!trimmed) continue
|
|
152
|
+
if (!isSafeDomainName(trimmed)) {
|
|
153
|
+
throw new TaskRunError(422, 'invalid_domain_name', `not a usable domain name: ${trimmed}`)
|
|
154
|
+
}
|
|
155
|
+
const key = trimmed.toLowerCase()
|
|
156
|
+
if (seen.has(key)) continue
|
|
157
|
+
seen.add(key)
|
|
158
|
+
names.push(trimmed)
|
|
159
|
+
}
|
|
160
|
+
updateProfileFields({ domains: names })
|
|
161
|
+
const resolved = taskDomains()
|
|
162
|
+
res.json({
|
|
163
|
+
domains: resolved.map(name => ({ name, abbr: domainAbbreviation(name), label: domainLabel(name) })),
|
|
164
|
+
configured: names,
|
|
165
|
+
gate: tasksGate(),
|
|
166
|
+
})
|
|
167
|
+
} catch (error) {
|
|
168
|
+
sendError(res, error)
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
112
172
|
tasksRouter.get('/tasks', async (req, res) => {
|
|
113
173
|
try {
|
|
114
174
|
const column = typeof req.query.column === 'string' ? req.query.column : undefined
|
|
@@ -123,14 +183,16 @@ tasksRouter.patch('/tasks/:id', async (req, res) => {
|
|
|
123
183
|
try {
|
|
124
184
|
const body = req.body ?? {}
|
|
125
185
|
const domain = requireDomain(body.domain)
|
|
126
|
-
if ('
|
|
186
|
+
if ('text' in body) {
|
|
187
|
+
await setTaskText(domain, req.params.id, String(body.text))
|
|
188
|
+
} else if ('runAt' in body) {
|
|
127
189
|
await setTaskRunAt(domain, req.params.id, body.runAt == null || body.runAt === '' ? null : String(body.runAt))
|
|
128
190
|
} else if ('section' in body) {
|
|
129
191
|
await moveTask(domain, req.params.id, String(body.section))
|
|
130
192
|
} else if ('checked' in body) {
|
|
131
193
|
await checkTask({ domain, id: req.params.id, checked: body.checked === true })
|
|
132
194
|
} else {
|
|
133
|
-
throw new TaskRunError(400, 'invalid_patch', 'Expected runAt, section, or checked.')
|
|
195
|
+
throw new TaskRunError(400, 'invalid_patch', 'Expected text, runAt, section, or checked.')
|
|
134
196
|
}
|
|
135
197
|
res.json({ ok: true })
|
|
136
198
|
} catch (error) {
|