@gotcos/glasses-server 6.44.0 → 6.44.2
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 +4 -2
- 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 +45 -7
- package/server/routes/tasks.ts +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
## 6.44.2
|
|
2
|
+
|
|
3
|
+
Task domains come from the user, not from the build.
|
|
4
|
+
|
|
5
|
+
- `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`.
|
|
6
|
+
- 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.
|
|
7
|
+
- Capture now rejects an unknown domain with the name it was given instead of "full domain required".
|
|
8
|
+
- 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.
|
|
9
|
+
|
|
10
|
+
## 6.44.1
|
|
11
|
+
|
|
12
|
+
Board and lens titles are readable.
|
|
13
|
+
|
|
14
|
+
- `projectRow` and `projectTaskRun` sliced the raw `tasks.md` line at 44 characters, so a title kept its markdown and was cut mid-word: 119 of the 201 rows served today began with an unbalanced `**`, and one row read `**Provide data migration process docs by ind`. Both now go through `taskTitle`, which strips code spans, links and emphasis first — buying back the characters the asterisks were spending — then cuts on a word boundary and marks the cut. Verified over all 312 live task lines: none over the cap, none with a stray asterisk, none empty.
|
|
15
|
+
- Underscore emphasis is anchored, so `cos_python` and `hermit_crabs` survive; only `_emphasis_` is stripped.
|
|
16
|
+
- `TASK_TITLE_MAX` is exported so tests pin the real cap instead of a copy of it.
|
|
17
|
+
|
|
1
18
|
## 6.44.0
|
|
2
19
|
|
|
3
20
|
Task store: one COS `tasks.md` contract with a durable lock, columns, and a run ledger.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.44.
|
|
3
|
+
"version": "6.44.2",
|
|
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": {
|
|
@@ -50,7 +50,9 @@
|
|
|
50
50
|
"url": "git+https://github.com/ukaoma/cos-glasses-server.git"
|
|
51
51
|
},
|
|
52
52
|
"homepage": "https://www.gotcos.com",
|
|
53
|
-
"bugs": {
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/ukaoma/cos-glasses-server/issues"
|
|
55
|
+
},
|
|
54
56
|
"publishConfig": {
|
|
55
57
|
"access": "public"
|
|
56
58
|
},
|
|
@@ -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'
|
|
@@ -329,10 +339,38 @@ export async function loadDomainRows(domain: TaskDomain, day: string): Promise<B
|
|
|
329
339
|
}
|
|
330
340
|
|
|
331
341
|
export async function loadAllRows(day: string): Promise<BridgeTaskRow[]> {
|
|
332
|
-
const groups = await Promise.all(
|
|
342
|
+
const groups = await Promise.all(taskDomains().map(domain => loadDomainRows(domain, day)))
|
|
333
343
|
return groups.flat()
|
|
334
344
|
}
|
|
335
345
|
|
|
346
|
+
/** Board and lens titles come from a raw tasks.md line, which carries markdown
|
|
347
|
+
* emphasis and is usually far longer than a row. Slicing the raw line cut words
|
|
348
|
+
* and left unbalanced `**` on 119 of 201 live rows, so a row read
|
|
349
|
+
* "**Provide data migration process docs by ind". Strip the markup first (which
|
|
350
|
+
* buys back the four characters the asterisks were spending), then cut on a word
|
|
351
|
+
* boundary. The cap is exported so tests pin the real value rather than a copy. */
|
|
352
|
+
export const TASK_TITLE_MAX = 44
|
|
353
|
+
|
|
354
|
+
export function taskTitle(line: string): string {
|
|
355
|
+
const plain = line
|
|
356
|
+
.replace(/`([^`]*)`/g, '$1')
|
|
357
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
358
|
+
// Underscore emphasis must be anchored: a bare `_` between word characters is
|
|
359
|
+
// an identifier (cos_python, hermit_crabs), not markup, and stripping it
|
|
360
|
+
// corrupts the row. Asterisks need no such care, so one catch-all below clears
|
|
361
|
+
// them whether the pair is balanced or not — and after a slice, it often is not.
|
|
362
|
+
.replace(/(^|[\s(])_([^_]+)_(?=[\s).,;:!?]|$)/g, '$1$2')
|
|
363
|
+
.replace(/\*+/g, '')
|
|
364
|
+
.replace(/\s+/g, ' ')
|
|
365
|
+
.trim()
|
|
366
|
+
if (plain.length <= TASK_TITLE_MAX) return plain
|
|
367
|
+
const cut = plain.slice(0, TASK_TITLE_MAX - 1)
|
|
368
|
+
const space = cut.lastIndexOf(' ')
|
|
369
|
+
// Only honour a word boundary that is not so early it throws the title away.
|
|
370
|
+
const body = space > TASK_TITLE_MAX * 0.6 ? cut.slice(0, space) : cut
|
|
371
|
+
return `${body.replace(/[\s\u2014\u2013,;:.-]+$/, '')}\u2026`
|
|
372
|
+
}
|
|
373
|
+
|
|
336
374
|
export function projectRow(
|
|
337
375
|
row: BridgeTaskRow,
|
|
338
376
|
ledger: readonly TaskRun[],
|
|
@@ -350,7 +388,7 @@ export function projectRow(
|
|
|
350
388
|
id: row.id,
|
|
351
389
|
ref: row.ref,
|
|
352
390
|
domain: row.domain,
|
|
353
|
-
title: row.description
|
|
391
|
+
title: taskTitle(row.description),
|
|
354
392
|
column: col,
|
|
355
393
|
priority: row.priority,
|
|
356
394
|
...(runAt ? { runAt: new Date(taskInstant(runAt.day, runAt.minutes, tz)).toISOString() } : {}),
|
|
@@ -392,8 +430,8 @@ export async function captureTask(body: {
|
|
|
392
430
|
if (!pythonBridgeAvailable()) throw new TaskRunError(503, 'cos_pipeline_not_configured', 'COS pipeline is not configured.')
|
|
393
431
|
if (!body.captureId) throw new TaskRunError(400, 'capture_id_required', 'captureId is required.')
|
|
394
432
|
if (!isClientJobId(body.captureId)) throw new TaskRunError(422, 'invalid_capture_id', 'captureId must be a UUID v4.')
|
|
395
|
-
if (!
|
|
396
|
-
throw new TaskRunError(400, 'invalid_domain',
|
|
433
|
+
if (!taskDomains().includes(body.domain)) {
|
|
434
|
+
throw new TaskRunError(400, 'invalid_domain', `unknown domain: ${body.domain}`)
|
|
397
435
|
}
|
|
398
436
|
const paths = taskStorePaths()
|
|
399
437
|
const seen = loadCapturesSeen(paths)
|
|
@@ -502,7 +540,7 @@ export function projectTaskRun(
|
|
|
502
540
|
run: TaskRun,
|
|
503
541
|
snapshot?: { status: string; completedAt?: string } | null,
|
|
504
542
|
): TaskRunView {
|
|
505
|
-
const title = run.line
|
|
543
|
+
const title = taskTitle(run.line)
|
|
506
544
|
const base = {
|
|
507
545
|
id: run.id,
|
|
508
546
|
kind: 'task' as const,
|
package/server/routes/tasks.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
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 } from '../lib/domains.js'
|
|
5
|
+
import { domainLabel } from '../lib/domain-label.js'
|
|
4
6
|
import {
|
|
5
7
|
TaskBridgeError,
|
|
6
8
|
TaskRunError,
|
|
@@ -12,6 +14,7 @@ import {
|
|
|
12
14
|
moveTask,
|
|
13
15
|
saveDispatchCap,
|
|
14
16
|
setTaskRunAt,
|
|
17
|
+
taskDomains,
|
|
15
18
|
tasksGate,
|
|
16
19
|
workBadgeCount,
|
|
17
20
|
} from '../lib/task-store.js'
|
|
@@ -109,6 +112,21 @@ tasksRouter.patch('/tasks/dispatch-cap', (req, res) => {
|
|
|
109
112
|
}
|
|
110
113
|
})
|
|
111
114
|
|
|
115
|
+
/** The domain list a client should offer, so no picker hardcodes one user's
|
|
116
|
+
* business units. `abbr` is derived server-side so every surface agrees on the
|
|
117
|
+
* badge; the two abbreviation maps that used to live in the app disagreed. */
|
|
118
|
+
tasksRouter.get('/domains', (_req, res) => {
|
|
119
|
+
try {
|
|
120
|
+
const names = taskDomains()
|
|
121
|
+
res.json({
|
|
122
|
+
domains: names.map(name => ({ name, abbr: domainAbbreviation(name), label: domainLabel(name) })),
|
|
123
|
+
gate: tasksGate(),
|
|
124
|
+
})
|
|
125
|
+
} catch (error) {
|
|
126
|
+
sendError(res, error)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
112
130
|
tasksRouter.get('/tasks', async (req, res) => {
|
|
113
131
|
try {
|
|
114
132
|
const column = typeof req.query.column === 'string' ? req.query.column : undefined
|