@gotcos/glasses-server 6.56.0 → 6.57.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 +39 -0
- package/docs/work-intake.md +32 -0
- package/package.json +3 -1
- package/server/index.ts +10 -0
- package/server/lib/control2-sandbox.ts +3 -1
- package/server/lib/cos-operations-meetings.ts +7 -0
- package/server/lib/jev.ts +156 -0
- package/server/lib/provider-binary.ts +6 -2
- package/server/lib/session-recommendation.ts +107 -0
- package/server/lib/task-store.ts +24 -0
- package/server/lib/work-intake-store.ts +221 -0
- package/server/routes/jev.ts +77 -0
- package/server/routes/work-intake.ts +136 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,42 @@
|
|
|
1
|
+
## 6.57.0
|
|
2
|
+
|
|
3
|
+
### Jev in the server: session suggestions and a key you can set from COS Control
|
|
4
|
+
|
|
5
|
+
- New `POST /api/work-board/session-recommendation` answers Continue, Fork or New for a Work task. The client sends only the sessions it can target (at most 80); the task and its meetings come from the board. Jev answers two questions in one request: which session shares this work's context (same deliverable, page, module, codebase or client thread), and which, if any, is already doing this exact task. Continue at 0.5 or above on the second, Fork when a session covers the context at 0.6 or above, otherwise New. Answers are cached for 30 minutes per task and session set. On Miles's live sessions a 1:1 task went New, a server bug continued its server session at 0.91, and a hardware-page task forked the hardware session; about 10K input tokens per task.
|
|
6
|
+
- Advice only: nothing is selected, sent or started. Without a key, over the cap, or while paused, the route answers `provider: none` and the client keeps its local word match.
|
|
7
|
+
- New `POST /api/jev-key/set`, `GET /api/jev-key/status` and `DELETE /api/jev-key`, like `/api/openai-key`: the key is validated live against TypeSafe's free model listing, saved to `data/jev-key.json` (0600), and never returned. A key saved this way wins over `TYPESAFE_API_KEY` in the environment or either `.env` file (both held one on Miles's Mac, which made a Settings key do nothing); the COS meeting producer resolves in the same order. Status says which source is active. A saved key works without a restart, and saving one clears a breaker left open by the old key.
|
|
8
|
+
- Cost controls: a daily input-token cap (`COS_JEV_DAILY_TOKENS`, default 1,000,000, about $0.04), a ledger in the data directory so a restart does not reset it, a 20-second timeout, and a breaker that pauses Jev for an hour after three consecutive failures. The model is pinned to `jev-1.13.0`.
|
|
9
|
+
|
|
10
|
+
### Work intake: meeting work that is not on the board yet
|
|
11
|
+
|
|
12
|
+
- New authenticated routes `GET /api/work-intake`, `POST /api/work-intake/items` and `POST /api/work-intake/:id/resolve` hold what the COS meeting producer (`work_intake.py`) found but did not write to Work: meeting links it was less sure of (0.60 to 0.85, or higher for a task that already links several meetings), other people's asks, and Miles's own items that need a look (from an older meeting, not clear enough to be a task, or owner unclear). Nothing here calls a model or starts an agent.
|
|
13
|
+
- Producer batches are validated per item. A bad item comes back in `rejected` with its code and never blocks the rest of the batch; only a malformed batch as a whole is refused. Unknown fields, unknown review reasons and a malformed session pull are dropped, not stored, and the item itself still arrives.
|
|
14
|
+
- A decision is sticky while it is retained (60 days). Once Miles accepts or dismisses an item, or the producer records a card it already made, a later producer upsert never reopens it. Resolution is serialized per item, so a double click answers "already being saved" instead of writing twice.
|
|
15
|
+
- Accepting a suggested link checks that the board can write, re-reads the task by its Work identity (it survives a rename), confirms the meeting is still the same saved record, uses the library's title, and retries up to twice on a revision race.
|
|
16
|
+
- Accepting an ask creates one Mentioned card linked to its meeting through one locked write in `task_write.capture_work_item`. Someone else's item is written with "(from Name)". If the same words are already an open task, that task is linked instead of adding a second card; the same words on closed, archived or delegated work are refused.
|
|
17
|
+
- Card creation needs a COS bridge that reports `captureWork`. Older bridges and the bundled task runtime report it off, and accepting an ask is refused with the code `card_creation_unavailable`. A bridge that fails or times out is reported as `bridgeUnavailable` (accept answers `task_bridge_unavailable`), never as an older server. Earlier servers read only `version === 1`, so the new capability is additive.
|
|
18
|
+
- The journal lives in the server data directory under `work-intake/` with one writer. A bad record is quarantined on its own and logged, and an unreadable journal leaves the rest of the API running. Unexpected failures are logged with the route. See `docs/work-intake.md`.
|
|
19
|
+
- The local Work candidate (`server/scripts/work-review-candidate.ts`) mounts Intake with its own journal in the candidate home, accepts producer-sized batches, and stays up if that journal is unreadable. It also mounts the Jev routes against the same board.
|
|
20
|
+
- Tests: `node server/scripts/work-intake-mutation-gate.mjs` runs 29 mutations over intake, the Jev client and session recommendation in a private copy, after a green baseline.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Session advice only suggests a Fork on a Claude or Codex session, the sessions a Work handoff can fork. A Cursor session that shares the context no longer produces a Fork that Send then refuses. A Fork's confidence is now the named session's own probability, not the chance that some session shares the context (a 0.5 and 0.48 split used to read 98%). A Jev answer without its "none" option is treated as a bad answer instead of a certain Fork. The board lookup tries a task's Work identity before its row id.
|
|
25
|
+
- The local Work candidate reads the installed Jev key but can no longer set or remove it, counts its own Jev spend in the candidate home, and accepts session-advice requests up to 128 KB (66 real sessions measured 20 KB, over the old 16 KB limit).
|
|
26
|
+
- The Control 2 sandbox probe's loopback listener accepted connections with no error handler. The bash probes close right after writing, so the accepted socket could see a reset. Vitest reported it as an unhandled error in 2 of 3 runs on 6.56.1, and in a running server it would have been uncaught.
|
|
27
|
+
|
|
28
|
+
## 6.56.1
|
|
29
|
+
|
|
30
|
+
### Codex runs again after the ChatGPT app update
|
|
31
|
+
|
|
32
|
+
- On 2026-09-27 a ChatGPT.app update (26.924, codex-cli 0.158.0-alpha.2.1) moved its bundled Codex CLI from `Contents/Resources/codex` to `Contents/Resources/codex-cli/bin/codex`, and `/Applications/Codex.app` is gone. Every Codex run (GPT models from the glasses, Codex engines) then failed with `codex binary unresolved (not_found)`. The server now looks in the new place first and still in the old one for older ChatGPT builds. The new location is a launcher that follows its own links, so nothing else changes.
|
|
33
|
+
- A test pins the order, and fails without the new location; the resolver finds it with no PATH and no `~/.codex/bin` link.
|
|
34
|
+
|
|
35
|
+
### Linking a saved meeting to Work
|
|
36
|
+
|
|
37
|
+
- Linking a saved COS meeting to a Work card no longer fails with "The selected meeting changed; refresh before linking". Opening a single operations meeting now returns the same record identity the meeting list shows, so the exact-meeting check in Work passes for an unchanged meeting and still refuses a different or renamed one.
|
|
38
|
+
- The earlier tests replaced the meeting resolver with a stub that always agreed. A new test links a real operations meeting, picked from the meeting list, through the production resolver.
|
|
39
|
+
|
|
1
40
|
## 6.56.0
|
|
2
41
|
|
|
3
42
|
### Connected Work across COS Control and glasses
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Work intake
|
|
2
|
+
|
|
3
|
+
Work intake holds meeting-derived Work that is not on the board yet. A producer outside the server (COS `operations/scripts/work_intake.py`, which uses Jev to judge saved meetings) pushes items; the user resolves them in COS Control → Work → Intake. The server never calls a model or starts an agent for intake.
|
|
4
|
+
|
|
5
|
+
## Items
|
|
6
|
+
|
|
7
|
+
| Kind | Status | Meaning | Accept does |
|
|
8
|
+
| --- | --- | --- | --- |
|
|
9
|
+
| `link` | `suggested` | An existing task was probably worked on in a meeting (0.60 to 0.85, or higher for a task that already links several meetings) | Links the task to the meeting |
|
|
10
|
+
| `ask` | `ask` | Someone else's action item. `pull` names one of the user's sessions that may already cover it | Creates a Mentioned card linked to the meeting, written with "(from Name)" |
|
|
11
|
+
| `ask` | `review` | The user's own item that needs a look. `reason`: `outside_window`, `unclear_task` or `owner_uncertain` | Creates a Mentioned card linked to the meeting |
|
|
12
|
+
| any | `accepted` / `dismissed` | Decided. A producer may record cards it made itself (`resolution.by: "producer"`) and may retract its own open items (a producer dismissal) | — |
|
|
13
|
+
|
|
14
|
+
## API (authenticated like every `/api` route)
|
|
15
|
+
|
|
16
|
+
- `GET /api/work-intake` returns `{ schemaVersion: 1, capabilities: { linkWrites, cardCreation, bridgeUnavailable? }, counts, items, quarantined }` with open items only.
|
|
17
|
+
- `POST /api/work-intake/items` takes `{ items: [...] }`, at most 200 per batch. Each item is validated on its own; refusals come back in `rejected: [{ id, code }]`.
|
|
18
|
+
- `POST /api/work-intake/:id/resolve` takes `{ action: "accept" | "dismiss" }`. Refusals are 409 with a code (`intake_task_changed`, `meeting_links_full`, `meeting_identity_changed`, `meeting_unavailable`, `intake_text_collision`, `work_board_read_only`, `card_creation_unavailable`, `intake_busy`), 503 `task_bridge_unavailable` when the task bridge did not answer, 404 `intake_not_found`. A refused item stays open.
|
|
19
|
+
|
|
20
|
+
Card creation needs a COS task bridge that reports `captureWork: 1`. The bundled task runtime does not, so on installs without COS the Intake list stays empty and accepting an ask is refused.
|
|
21
|
+
|
|
22
|
+
## Storage and recovery
|
|
23
|
+
|
|
24
|
+
The journal is `work-intake/intake.json` in the server data directory, owned by one server process (`writer.lock`). User decisions are kept for 60 days and are sticky against producer upserts; open items are capped at 5,000. An unreadable record is quarantined on its own and logged at start. An unreadable or unsafe journal disables intake (503) without stopping the rest of the server: preserve the file for diagnosis, then move it aside to start empty. The producer's own ledger (`.work_intake_state.json`) never re-sends a decided item.
|
|
25
|
+
|
|
26
|
+
## Verification
|
|
27
|
+
|
|
28
|
+
`npx vitest run server/lib/work-intake-store.test.ts server/routes/work-intake.test.ts server/lib/task-store-intake.test.ts` covers the store, routes and bridge powers. `node server/scripts/work-intake-mutation-gate.mjs` runs its mutations in a private copy and refuses to start on a red baseline. The local Work candidate (`server/scripts/work-review-candidate.ts`) mounts intake with a journal in the candidate home.
|
|
29
|
+
|
|
30
|
+
## Jev key and session suggestions
|
|
31
|
+
|
|
32
|
+
`POST /api/jev-key/set` `{ key }` validates the key against TypeSafe and saves it to `data/jev-key.json`; `GET /api/jev-key/status` returns `{ configured, source, usedToday, dailyCap, breakerOpenUntil, lastError }` and never the key; `DELETE /api/jev-key` removes the saved key. `POST /api/work-board/session-recommendation` `{ domain, id, sessions: [{ id, provider, title, summary? }] }` returns `{ provider: "jev", action: "continue" | "fork" | "new", sessionId, confidence, reason, alternatives }` or `{ provider: "none", reason }`. The COS meeting producer reads the same saved key when its own `.env` has none.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.57.0",
|
|
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": {
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"!server/scripts/engine-canary.ts",
|
|
44
44
|
"!server/scripts/work-review-candidate.ts",
|
|
45
45
|
"!server/scripts/work-review-mutation-gate.mjs",
|
|
46
|
+
"!server/scripts/work-intake-mutation-gate.mjs",
|
|
46
47
|
"!server/scripts/mutation-gate.mjs",
|
|
47
48
|
"!server/lib/meeting-engine/__fixtures__/**",
|
|
48
49
|
"!server/lib/__fixtures__/**",
|
|
@@ -62,6 +63,7 @@
|
|
|
62
63
|
"docs/control2-sandbox.md",
|
|
63
64
|
"docs/control2-task-runtime.md",
|
|
64
65
|
"docs/work-reviews.md",
|
|
66
|
+
"docs/work-intake.md",
|
|
65
67
|
"work-task-runtime",
|
|
66
68
|
"docs/work-task-runtime.md",
|
|
67
69
|
"!server/scripts/build-work-task-runtime.py"
|
package/server/index.ts
CHANGED
|
@@ -124,6 +124,10 @@ import { getTranscriptionProfileStatus } from './lib/profile.js'
|
|
|
124
124
|
import { createQueryJobsRouter } from './routes/query-jobs.js'
|
|
125
125
|
import { createWorkReviewsRouter } from './routes/work-reviews.js'
|
|
126
126
|
import { createWorkBoardRouter } from './routes/work-board.js'
|
|
127
|
+
import { createWorkIntakeRouter } from './routes/work-intake.js'
|
|
128
|
+
import { createJevRouter } from './routes/jev.js'
|
|
129
|
+
import { dataPath } from './lib/data-dir.js'
|
|
130
|
+
import { WorkIntakeStore, createOptionalWorkIntakeStore } from './lib/work-intake-store.js'
|
|
127
131
|
import { createDefaultWorkReviewRuntime } from './lib/work-review-backend.js'
|
|
128
132
|
import { createMorningBriefRouter } from './routes/morning-brief.js'
|
|
129
133
|
import { getMorningBriefScheduler, startMorningBriefScheduler, stopMorningBriefScheduler } from './lib/morning-brief-runtime.js'
|
|
@@ -622,6 +626,11 @@ app.use('/api', createQueryJobsRouter(queryJobCoordinator, {
|
|
|
622
626
|
const workReviewRuntime = createDefaultWorkReviewRuntime()
|
|
623
627
|
app.use('/api', createWorkReviewsRouter(workReviewRuntime))
|
|
624
628
|
app.use('/api', createWorkBoardRouter())
|
|
629
|
+
// Work intake (6.57.0): meeting-derived suggestions, asks and review items. Optional: a bad journal never blocks boot.
|
|
630
|
+
const workIntakeStore = createOptionalWorkIntakeStore(() => new WorkIntakeStore(dataPath('work-intake')))
|
|
631
|
+
app.use('/api', createWorkIntakeRouter({ store: workIntakeStore }))
|
|
632
|
+
// Jev key (Control Settings) and Continue/Fork/New session recommendations for Work tasks (6.57.0).
|
|
633
|
+
app.use('/api', createJevRouter())
|
|
625
634
|
app.use('/api', queryRouter)
|
|
626
635
|
// The scheduled start-of-day brief: settings, status, run-now. Same auth as
|
|
627
636
|
// every other settings route; the brief itself is an ordinary durable job.
|
|
@@ -937,6 +946,7 @@ async function gracefulShutdown(): Promise<void> {
|
|
|
937
946
|
forceExit.unref?.()
|
|
938
947
|
stopMorningBriefScheduler()
|
|
939
948
|
await workReviewRuntime?.close()
|
|
949
|
+
workIntakeStore?.close()
|
|
940
950
|
stopMeetingImportScheduler()
|
|
941
951
|
stopMeetingMergeScheduler()
|
|
942
952
|
sessionHooksRuntime.stop()
|
|
@@ -139,7 +139,9 @@ export async function probeControl2Sandbox(): Promise<Control2SandboxCapability>
|
|
|
139
139
|
const protectedRoot = mkdtempSync(join(tmpdir(), 'cos-control2-protected-'))
|
|
140
140
|
const sentinel = join(realpathSync(protectedRoot), 'sentinel.txt')
|
|
141
141
|
writeFileSync(sentinel, 'protected-canary-only', { mode: 0o600 })
|
|
142
|
-
|
|
142
|
+
// 6.57.0: the bash probes close right after writing, so the accepted socket can see ECONNRESET.
|
|
143
|
+
// Without a listener that 'error' was unhandled (vitest reported it in 2 of 3 runs on 6.56.1).
|
|
144
|
+
const listener = createServer(socket => { socket.on('error', () => {}); socket.end('reachable') })
|
|
143
145
|
let hits = 0
|
|
144
146
|
listener.on('connection', () => { hits++ })
|
|
145
147
|
try {
|
|
@@ -828,6 +828,13 @@ export function getCosOperationsMeetingDetail(
|
|
|
828
828
|
const content = readFileSync(filepath, 'utf-8')
|
|
829
829
|
const meta = parseMeetingMeta(content, resolvedFilename, domain)
|
|
830
830
|
meta.month = month
|
|
831
|
+
// Same identity the list row advertises (listCosOperationsMeetings). Work's
|
|
832
|
+
// meeting link and review checks compare the list's recordId against this
|
|
833
|
+
// detail; without it every real COS meeting refused as "meeting changed".
|
|
834
|
+
meta.librarySource = 'cos_operations'
|
|
835
|
+
meta.recordId = `ops:${domain}:${month}:${resolvedFilename}`
|
|
836
|
+
meta.mutable = true
|
|
837
|
+
meta.canonicalRecord = `operations/${domain}/meetings/${month}/${resolvedFilename}`
|
|
831
838
|
const source = boundedMeetingSource(content)
|
|
832
839
|
|
|
833
840
|
return {
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jev (TypeSafe System One): a classifier that answers typed questions with calibrated probabilities. It never
|
|
3
|
+
* writes text. Used for Work session matching (6.57.0); the COS meeting producer uses the same key.
|
|
4
|
+
*
|
|
5
|
+
* Key resolution: data/jev-key.json (saved from COS Control Settings, validated live, never echoed back) wins, then
|
|
6
|
+
* TYPESAFE_API_KEY in the environment (which includes ~/.cos-glasses/.env, loaded at boot), then the COS scripts
|
|
7
|
+
* .env. Unlike the OpenAI key the saved key comes FIRST: Miles's Mac has TYPESAFE_API_KEY in both .env files, so an
|
|
8
|
+
* environment-first order made the Settings key inert (QA 2026-09-28). The COS producer (work_intake.py) resolves
|
|
9
|
+
* in the same order: saved, its environment, then the other .env.
|
|
10
|
+
*
|
|
11
|
+
* Cost controls: a daily input-token cap (COS_JEV_DAILY_TOKENS, default 1,000,000, about $0.04 at $42 per billion),
|
|
12
|
+
* a breaker that opens for an hour after three consecutive failures, and a 20-second request timeout. Every call
|
|
13
|
+
* counts against a ledger in the data directory, so a restart does not reset the cap.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
16
|
+
import { dirname, resolve } from 'node:path'
|
|
17
|
+
import { COS_SCRIPTS_DIR } from './python-bridge.js'
|
|
18
|
+
import { dataPath } from './data-dir.js'
|
|
19
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
20
|
+
import { secureExistingPrivateFile } from './secure-user-config.js'
|
|
21
|
+
|
|
22
|
+
export const JEV_ENDPOINT = 'https://api.typesafe.ai/v1'
|
|
23
|
+
export const JEV_MODEL = 'jev-1.13.0'
|
|
24
|
+
export const JEV_KEY_FILE = dataPath('jev-key.json')
|
|
25
|
+
export const JEV_USAGE_FILE = dataPath('jev-usage.json')
|
|
26
|
+
export const JEV_LIMITS = { defaultDailyTokens: 1_000_000, timeoutMs: 20_000, breakerFailures: 3, breakerMs: 3_600_000 } as const
|
|
27
|
+
|
|
28
|
+
export type JevKeySource = 'env' | 'config' | 'scripts-env' | 'none'
|
|
29
|
+
export class JevError extends Error {
|
|
30
|
+
constructor(readonly code: 'jev_not_configured' | 'jev_cap_reached' | 'jev_breaker_open' | 'jev_key_rejected'
|
|
31
|
+
| 'jev_request_rejected' | 'jev_unavailable' | 'jev_bad_answer', message: string = code) { super(message) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface KeyFile { key: string; savedAt: string; validatedAt?: string }
|
|
35
|
+
function readKeyFile(): KeyFile | null {
|
|
36
|
+
if (!existsSync(JEV_KEY_FILE)) return null
|
|
37
|
+
try {
|
|
38
|
+
secureExistingPrivateFile(JEV_KEY_FILE)
|
|
39
|
+
const parsed = JSON.parse(readFileSync(JEV_KEY_FILE, 'utf8')) as Partial<KeyFile>
|
|
40
|
+
return typeof parsed.key === 'string' && parsed.key.trim()
|
|
41
|
+
? { key: parsed.key.trim(), savedAt: String(parsed.savedAt ?? ''), validatedAt: parsed.validatedAt } : null
|
|
42
|
+
} catch { return null }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Resolved fresh on every call (a small file read), so a key saved from Control works without a restart. */
|
|
46
|
+
export function resolveJevKey(): { key: string; source: Exclude<JevKeySource, 'none'>; savedAt?: string; validatedAt?: string } | null {
|
|
47
|
+
const file = readKeyFile()
|
|
48
|
+
if (file) return { key: file.key, source: 'config', savedAt: file.savedAt, validatedAt: file.validatedAt }
|
|
49
|
+
const env = process.env.TYPESAFE_API_KEY?.trim().replace(/^["']|["']$/g, '')
|
|
50
|
+
if (env) return { key: env, source: 'env' }
|
|
51
|
+
if (COS_SCRIPTS_DIR) {
|
|
52
|
+
for (const path of [resolve(COS_SCRIPTS_DIR, '.env'), resolve(COS_SCRIPTS_DIR, '../../.env')]) {
|
|
53
|
+
try {
|
|
54
|
+
const match = readFileSync(path, 'utf8').match(/^TYPESAFE_API_KEY=(.+)$/m)
|
|
55
|
+
if (match?.[1].trim()) return { key: match[1].trim().replace(/^["']|["']$/g, ''), source: 'scripts-env' }
|
|
56
|
+
} catch { /* next */ }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function saveJevKey(key: string, now = new Date()): void {
|
|
63
|
+
mkdirSync(dirname(JEV_KEY_FILE), { recursive: true, mode: 0o700 })
|
|
64
|
+
const at = now.toISOString()
|
|
65
|
+
durableAtomicWriteFileSync(JEV_KEY_FILE, JSON.stringify({ key, savedAt: at, validatedAt: at }, null, 2) + '\n', { mode: 0o600 })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type Fetch = typeof fetch
|
|
69
|
+
/** A 200 from the free model listing proves the key works without spending tokens. */
|
|
70
|
+
export async function validateJevKey(key: string, fetchImpl: Fetch = fetch): Promise<{ ok: true } | { ok: false; status: number; reason: string }> {
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetchImpl(`${JEV_ENDPOINT}/models`, { headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(8_000) })
|
|
73
|
+
if (res.ok) return { ok: true }
|
|
74
|
+
return { ok: false, status: res.status, reason: res.status === 401 || res.status === 403 ? 'TypeSafe did not accept this key.' : `TypeSafe returned ${res.status}.` }
|
|
75
|
+
} catch { return { ok: false, status: 0, reason: 'TypeSafe could not be reached. Check the network and try again.' } }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function today(now: Date): string { return now.toISOString().slice(0, 10) }
|
|
79
|
+
function dailyCap(): number {
|
|
80
|
+
const raw = Number(process.env.COS_JEV_DAILY_TOKENS)
|
|
81
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : JEV_LIMITS.defaultDailyTokens
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface JevAnswers { answers: Record<string, { choice?: string; probabilities?: Record<string, number>; noul?: number }>; model?: string; inputTokens: number }
|
|
85
|
+
|
|
86
|
+
/** One Jev client per process: it owns the breaker and the usage ledger. */
|
|
87
|
+
export class JevClient {
|
|
88
|
+
private failures = 0
|
|
89
|
+
private breakerUntil = 0
|
|
90
|
+
private lastError: string | null = null
|
|
91
|
+
constructor(private readonly fetchImpl: Fetch = fetch, private readonly now: () => Date = () => new Date(),
|
|
92
|
+
private readonly usageFile = JEV_USAGE_FILE) {}
|
|
93
|
+
|
|
94
|
+
usedToday(): number {
|
|
95
|
+
try {
|
|
96
|
+
const ledger = JSON.parse(readFileSync(this.usageFile, 'utf8')) as Record<string, number>
|
|
97
|
+
return Number(ledger[today(this.now())]) || 0
|
|
98
|
+
} catch { return 0 }
|
|
99
|
+
}
|
|
100
|
+
private record(tokens: number): void {
|
|
101
|
+
let ledger: Record<string, number> = {}
|
|
102
|
+
try { ledger = JSON.parse(readFileSync(this.usageFile, 'utf8')) } catch { /* fresh */ }
|
|
103
|
+
const day = today(this.now())
|
|
104
|
+
const kept = Object.fromEntries(Object.entries(ledger).filter(([d]) => d >= today(new Date(this.now().getTime() - 31 * 86_400_000))))
|
|
105
|
+
kept[day] = (Number(kept[day]) || 0) + tokens
|
|
106
|
+
mkdirSync(dirname(this.usageFile), { recursive: true, mode: 0o700 })
|
|
107
|
+
durableAtomicWriteFileSync(this.usageFile, JSON.stringify(kept) + '\n', { mode: 0o600 })
|
|
108
|
+
}
|
|
109
|
+
private fail(code: JevError['code'], countsTowardBreaker: boolean): never {
|
|
110
|
+
this.lastError = code
|
|
111
|
+
if (countsTowardBreaker && ++this.failures >= JEV_LIMITS.breakerFailures) {
|
|
112
|
+
this.breakerUntil = this.now().getTime() + JEV_LIMITS.breakerMs
|
|
113
|
+
this.failures = 0
|
|
114
|
+
}
|
|
115
|
+
throw new JevError(code)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A newly validated key starts clean: failures under the old key must not pause the new one for an hour. */
|
|
119
|
+
resetForNewKey(): void { this.failures = 0; this.breakerUntil = 0; this.lastError = null }
|
|
120
|
+
|
|
121
|
+
status(): { configured: boolean; source: JevKeySource; savedAt?: string; validatedAt?: string; usedToday: number; dailyCap: number; breakerOpenUntil: string | null; lastError: string | null } {
|
|
122
|
+
const key = resolveJevKey()
|
|
123
|
+
return { configured: !!key, source: key?.source ?? 'none', savedAt: key?.savedAt, validatedAt: key?.validatedAt,
|
|
124
|
+
usedToday: this.usedToday(), dailyCap: dailyCap(),
|
|
125
|
+
breakerOpenUntil: this.breakerUntil > this.now().getTime() ? new Date(this.breakerUntil).toISOString() : null, lastError: this.lastError }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Ask one request. `estimate` is the caller's input-token estimate, checked against the cap BEFORE sending. */
|
|
129
|
+
async ask(state: unknown, questions: Record<string, unknown>, estimate: number): Promise<JevAnswers> {
|
|
130
|
+
const key = resolveJevKey()
|
|
131
|
+
if (!key) throw new JevError('jev_not_configured')
|
|
132
|
+
if (this.breakerUntil > this.now().getTime()) throw new JevError('jev_breaker_open')
|
|
133
|
+
if (this.usedToday() + estimate > dailyCap()) this.fail('jev_cap_reached', false)
|
|
134
|
+
let res: Response
|
|
135
|
+
try {
|
|
136
|
+
res = await this.fetchImpl(`${JEV_ENDPOINT}/systemone`, { method: 'POST', signal: AbortSignal.timeout(JEV_LIMITS.timeoutMs),
|
|
137
|
+
headers: { Authorization: `Bearer ${key.key}`, 'Content-Type': 'application/json' },
|
|
138
|
+
body: JSON.stringify({ state, model: JEV_MODEL, questions }) })
|
|
139
|
+
} catch { return this.fail('jev_unavailable', true) }
|
|
140
|
+
if (res.status === 401 || res.status === 403 || res.status === 402) return this.fail('jev_key_rejected', true)
|
|
141
|
+
if (res.status === 400 || res.status === 413 || res.status === 422) return this.fail('jev_request_rejected', false)
|
|
142
|
+
if (!res.ok) return this.fail('jev_unavailable', true)
|
|
143
|
+
let body: { answers?: JevAnswers['answers']; model?: string; usage?: { input_tokens?: number } }
|
|
144
|
+
try { body = await res.json() } catch { return this.fail('jev_bad_answer', true) }
|
|
145
|
+
const used = Number(body.usage?.input_tokens) || estimate // count something even if usage is missing
|
|
146
|
+
this.record(used)
|
|
147
|
+
if (!body.answers || typeof body.answers !== 'object') return this.fail('jev_bad_answer', true)
|
|
148
|
+
this.failures = 0; this.lastError = null
|
|
149
|
+
return { answers: body.answers, model: body.model, inputTokens: used }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let shared: JevClient | null = null
|
|
154
|
+
export function sharedJevClient(): JevClient { return shared ??= new JevClient() }
|
|
155
|
+
/** Jev bills by input size; about 2.6 characters per token, measured on COS meeting and task text. */
|
|
156
|
+
export function estimateJevTokens(value: unknown): number { return Math.ceil(JSON.stringify(value).length / 2.6) + 1 }
|
|
@@ -118,8 +118,12 @@ export function providerBinarySpec(provider: string): BinarySpec | null {
|
|
|
118
118
|
name: 'codex',
|
|
119
119
|
envKeys: ['COS_ATTACHED_CODEX_BIN', 'COS_CODEX_BIN'],
|
|
120
120
|
absolutes: [
|
|
121
|
-
//
|
|
122
|
-
//
|
|
121
|
+
// 6.56.1: ChatGPT.app 26.924 (2026-09-27, codex-cli 0.158.0-alpha.2.1) moved the bundled CLI
|
|
122
|
+
// here, a sh launcher that follows symlinks to CodexCLI.app. Every Codex run on an updated
|
|
123
|
+
// Mac failed with `codex binary unresolved (not_found)` until this path was listed.
|
|
124
|
+
'/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex',
|
|
125
|
+
// Verified 2026-08-15: codex-cli 0.148.0-alpha.9 lived here (older ChatGPT builds), and
|
|
126
|
+
// there is no `codex` on PATH at all on this machine.
|
|
123
127
|
'/Applications/ChatGPT.app/Contents/Resources/codex',
|
|
124
128
|
home ? join(home, '.codex', 'bin', 'codex') : '',
|
|
125
129
|
'/opt/homebrew/bin/codex',
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of the user's agent sessions should take a Work task: Continue one already doing it, Fork one that shares
|
|
3
|
+
* its context (same deliverable, page, module, codebase or client thread) but is doing different work, or start a
|
|
4
|
+
* New session. Jev answers two choice questions over the sessions in one request; the thresholds below were
|
|
5
|
+
* checked on Miles's live sessions on 2026-09-28 (a 1:1 task went New, a server bug Continued its server session at
|
|
6
|
+
* 0.91, a hardware-page task Forked the hardware session). A recommendation is advice: nothing is selected or sent.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
9
|
+
import { estimateJevTokens, JevError, type JevClient } from './jev.js'
|
|
10
|
+
|
|
11
|
+
export const CONTINUE_AT = 0.5 // P(a session is already doing this exact task)
|
|
12
|
+
export const FORK_AT = 0.6 // P(some session shares the context) = 1 - P(none)
|
|
13
|
+
export const RECOMMENDATION_LIMITS = { sessions: 80, title: 120, summary: 240, cacheMs: 30 * 60_000, cacheEntries: 200 } as const
|
|
14
|
+
|
|
15
|
+
export interface CandidateSession { id: string; provider: string; title: string; summary?: string; updated?: string }
|
|
16
|
+
export interface RecommendationInput { task: { text: string; domain: string; meetings: string[] }; sessions: CandidateSession[] }
|
|
17
|
+
export type Recommendation =
|
|
18
|
+
| { provider: 'jev'; model: string; action: 'continue' | 'fork' | 'new'; sessionId: string | null; confidence: number; reason: string;
|
|
19
|
+
alternatives: Array<{ sessionId: string; p: number }>; cached: boolean }
|
|
20
|
+
| { provider: 'none'; reason: string }
|
|
21
|
+
|
|
22
|
+
const BEST = "Which one of the user's agent sessions is the natural place to do the work in `task`: the session already working on the "
|
|
23
|
+
+ 'same deliverable, page, module, codebase or client thread, so continuing or forking it saves re-explaining context? '
|
|
24
|
+
+ 'Choose none if no session works on the same deliverable, page, module, codebase or client thread.'
|
|
25
|
+
const EXACT = "Which one of the user's agent sessions is already doing this exact task, so the work should simply continue there? "
|
|
26
|
+
+ 'A session on the same project doing different work does not count. Choose none if no session is doing this task.'
|
|
27
|
+
|
|
28
|
+
/** Providers the Work handoff can fork (Control's destinationSupported). */
|
|
29
|
+
export const FORKABLE = new Set(['claude', 'codex'])
|
|
30
|
+
const clip = (text: string | undefined, max: number) => (text ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
|
|
31
|
+
const round = (n: number) => Math.round(n * 100) / 100
|
|
32
|
+
|
|
33
|
+
function ranked(probabilities: Record<string, number> | undefined, offered: Set<string>): Array<[string, number]> {
|
|
34
|
+
const rows = Object.entries(probabilities ?? {}).filter(([k]) => k !== 'none')
|
|
35
|
+
if (rows.some(([k]) => !offered.has(k))) throw new JevError('jev_bad_answer', 'Jev chose a session that was not offered')
|
|
36
|
+
return rows.map(([k, v]) => [k, Number(v) || 0] as [string, number]).sort((a, b) => b[1] - a[1])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Pure policy over Jev's two answers. Exported for tests. */
|
|
40
|
+
export function decide(best: Record<string, number>, exact: Record<string, number>, sessions: CandidateSession[]):
|
|
41
|
+
Omit<Extract<Recommendation, { provider: 'jev' }>, 'provider' | 'model' | 'cached'> {
|
|
42
|
+
const offered = new Set(sessions.map((_, i) => `s${i}`))
|
|
43
|
+
const bestRank = ranked(best, offered), exactRank = ranked(exact, offered)
|
|
44
|
+
const idOf = (key: string) => sessions[Number(key.slice(1))].id
|
|
45
|
+
const alternatives = bestRank.slice(0, 3).filter(([, p]) => p > 0).map(([k, p]) => ({ sessionId: idOf(k), p: round(p) }))
|
|
46
|
+
const [exactKey, exactP] = exactRank[0] ?? ['', 0]
|
|
47
|
+
if (exactKey && exactP >= CONTINUE_AT) {
|
|
48
|
+
return { action: 'continue', sessionId: idOf(exactKey), confidence: round(exactP), reason: 'Already doing this task.', alternatives }
|
|
49
|
+
}
|
|
50
|
+
// A choice answer always carries `none`; without it "covered" would read 1 and advise a Fork at 100%.
|
|
51
|
+
if (typeof best.none !== 'number' || !Number.isFinite(best.none)) throw new JevError('jev_bad_answer', 'Jev left out none')
|
|
52
|
+
const covered = 1 - best.none
|
|
53
|
+
// Fork only where the workspace can fork (Claude and Codex); a Cursor session sharing context cannot take a Fork.
|
|
54
|
+
const [forkKey, forkP] = bestRank.find(([k, p]) => p > 0 && FORKABLE.has(sessions[Number(k.slice(1))].provider)) ?? ['', 0]
|
|
55
|
+
if (forkKey && covered >= FORK_AT) {
|
|
56
|
+
// The confidence shown is the named session's own probability, not the chance that SOME session shares context.
|
|
57
|
+
return { action: 'fork', sessionId: idOf(forkKey), confidence: round(forkP),
|
|
58
|
+
reason: 'Shares this work’s context (same deliverable, page, module or thread) but is doing different work.', alternatives }
|
|
59
|
+
}
|
|
60
|
+
return { action: 'new', sessionId: null, confidence: round(1 - covered), reason: 'No existing session shares this work’s context.', alternatives }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Validate what the client sends. Sessions are candidates only: the task text always comes from the board. */
|
|
64
|
+
export function parseCandidates(raw: unknown): CandidateSession[] | null {
|
|
65
|
+
if (!Array.isArray(raw) || raw.length > RECOMMENDATION_LIMITS.sessions) return null
|
|
66
|
+
const seen = new Set<string>(), out: CandidateSession[] = []
|
|
67
|
+
for (const r of raw as Array<Record<string, unknown>>) {
|
|
68
|
+
if (!r || typeof r !== 'object' || typeof r.id !== 'string' || !r.id || r.id.length > 200 || seen.has(r.id)) return null
|
|
69
|
+
if (typeof r.title !== 'string' || !clip(r.title, RECOMMENDATION_LIMITS.title)) return null
|
|
70
|
+
seen.add(r.id)
|
|
71
|
+
out.push({ id: r.id, provider: clip(typeof r.provider === 'string' ? r.provider : 'agent', 20), title: clip(r.title, RECOMMENDATION_LIMITS.title),
|
|
72
|
+
summary: clip(typeof r.summary === 'string' ? r.summary : '', RECOMMENDATION_LIMITS.summary), updated: clip(typeof r.updated === 'string' ? r.updated : '', 10) })
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class SessionRecommender {
|
|
78
|
+
private cache = new Map<string, { at: number; value: Extract<Recommendation, { provider: 'jev' }> }>()
|
|
79
|
+
constructor(private readonly jev: JevClient, private readonly now: () => number = () => Date.now()) {}
|
|
80
|
+
|
|
81
|
+
async recommend(input: RecommendationInput): Promise<Recommendation> {
|
|
82
|
+
const sessions = input.sessions
|
|
83
|
+
if (!sessions.length) return { provider: 'none', reason: 'no_sessions' }
|
|
84
|
+
const key = createHash('sha256').update(JSON.stringify(input)).digest('hex')
|
|
85
|
+
const hit = this.cache.get(key)
|
|
86
|
+
if (hit && this.now() - hit.at < RECOMMENDATION_LIMITS.cacheMs) return { ...hit.value, cached: true }
|
|
87
|
+
const state = { task: { text: clip(input.task.text, 1200), domain: input.task.domain, meetings: input.task.meetings.map(t => clip(t, 160)).slice(0, 8) },
|
|
88
|
+
sessions: sessions.map((s, i) => ({ i, provider: s.provider, title: s.title, summary: s.summary, updated: s.updated })) }
|
|
89
|
+
const options = Object.fromEntries(sessions.map((s, i) => [`s${i}`, `sessions[${i}]: ${clip(s.title, 100)}`]))
|
|
90
|
+
const questions = {
|
|
91
|
+
best: { type: 'choice', criteria: { ...options, none: 'No listed session shares this work’s context.' }, instructions: BEST },
|
|
92
|
+
exact: { type: 'choice', criteria: { ...options, none: 'No listed session is doing this task.' }, instructions: EXACT },
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const reply = await this.jev.ask(state, questions, estimateJevTokens({ state, questions }))
|
|
96
|
+
const best = reply.answers.best?.probabilities, exact = reply.answers.exact?.probabilities
|
|
97
|
+
if (!best || !exact) throw new JevError('jev_bad_answer')
|
|
98
|
+
const value = { provider: 'jev' as const, model: reply.model ?? 'jev', cached: false, ...decide(best, exact, sessions) }
|
|
99
|
+
if (this.cache.size >= RECOMMENDATION_LIMITS.cacheEntries) this.cache.delete(this.cache.keys().next().value!)
|
|
100
|
+
this.cache.set(key, { at: this.now(), value })
|
|
101
|
+
return value
|
|
102
|
+
} catch (e) {
|
|
103
|
+
if (e instanceof JevError) return { provider: 'none', reason: e.code }
|
|
104
|
+
throw e
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
package/server/lib/task-store.ts
CHANGED
|
@@ -560,6 +560,30 @@ export async function setTaskWorkStage(domain: string, id: string, workStage: Wo
|
|
|
560
560
|
if (!WORK_STAGES.includes(workStage)) throw new TaskRunError(422, 'invalid_work_stage', 'Unknown Work stage')
|
|
561
561
|
await withLockRetry(() => bridge(['task-set-work-stage', domain, id, workStage], JSON.stringify({ expectedText, expectedRevision })))
|
|
562
562
|
}
|
|
563
|
+
/**
|
|
564
|
+
* Work intake needs two bridge powers: exact meeting links (version 1) and one-write card capture. A bridge that
|
|
565
|
+
* failed or timed out is reported as `bridgeUnavailable`, so a hiccup never reads as "this server is too old".
|
|
566
|
+
*/
|
|
567
|
+
export async function workIntakeCapabilities(): Promise<{ linkWrites: boolean; cardCreation: boolean; bridgeUnavailable?: true }> {
|
|
568
|
+
if (!taskBridgeAvailable()) return { linkWrites: false, cardCreation: false }
|
|
569
|
+
try {
|
|
570
|
+
const value = await bridge(['task-work-capabilities']) as { version?: unknown; captureWork?: unknown }
|
|
571
|
+
// `captureWork` is additive on purpose: servers through 6.56.x read only `version === 1`.
|
|
572
|
+
return { linkWrites: value?.version === 1, cardCreation: value?.version === 1 && value?.captureWork === 1 }
|
|
573
|
+
} catch { return { linkWrites: false, cardCreation: false, bridgeUnavailable: true } }
|
|
574
|
+
}
|
|
575
|
+
export interface CapturedWorkItem { created: boolean; id: string; linked: boolean; checked: boolean; archived: boolean; delegated: boolean }
|
|
576
|
+
/** One locked write: a Mentioned card already linked to its meeting (COS `task_write.capture_work_item`). */
|
|
577
|
+
export async function captureWorkItem(domain: string, text: string, meeting: WorkMeetingRef): Promise<CapturedWorkItem> {
|
|
578
|
+
if (!isSafeDomainName(domain) || !taskDomains().includes(domain)) throw new TaskRunError(400, 'invalid_domain', 'Unknown task domain')
|
|
579
|
+
if (!text || text.length > 2000) throw new TaskRunError(422, 'invalid_task_text', 'Card text must be 1 to 2,000 characters')
|
|
580
|
+
rejectReservedWorkText(text)
|
|
581
|
+
const out = await withLockRetry(() => bridge(['task-capture-work', domain], JSON.stringify({ text, meeting }))) as Partial<CapturedWorkItem> & { ok?: unknown }
|
|
582
|
+
if (out?.ok !== true || typeof out.id !== 'string' || !/^[a-f0-9]{12}$/.test(out.id) || typeof out.created !== 'boolean' || typeof out.linked !== 'boolean') {
|
|
583
|
+
throw new TaskRunError(502, 'capture_unverified', 'The card could not be confirmed. Refresh Work before trying again.')
|
|
584
|
+
}
|
|
585
|
+
return { created: out.created, id: out.id, linked: out.linked, checked: out.checked === true, archived: out.archived === true, delegated: out.delegated === true }
|
|
586
|
+
}
|
|
563
587
|
export async function linkTaskMeeting(domain: string, id: string, expectedText: string, expectedRevision: string, meeting: WorkMeetingRef): Promise<void> {
|
|
564
588
|
validateWorkTarget(domain, id, expectedText, expectedRevision)
|
|
565
589
|
await withLockRetry(() => bridge(['task-link-meeting', domain, id], JSON.stringify({ expectedText, expectedRevision, meeting })))
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work intake: meeting-derived Work that is not yet on the board, and cards a producer already made.
|
|
3
|
+
*
|
|
4
|
+
* A producer (COS `work_intake.py`, Jev-scored) upserts items; the user resolves them in Control.
|
|
5
|
+
* Suggested meeting links (0.60 to 0.85, or higher for a task already linked to several meetings), other people's
|
|
6
|
+
* asks, and review items (his own, from an older meeting or not clear enough to card)
|
|
7
|
+
* live here, never in tasks.md: task metadata accepts only workflowPhase/workIdentity/meetingRefs, and an
|
|
8
|
+
* older validator would mark a whole task line invalid if a suggestion were stored on it.
|
|
9
|
+
*
|
|
10
|
+
* One process owns the journal (instance lock). A bad record is quarantined on its own instead of taking
|
|
11
|
+
* the store down. A user's decision is sticky while it is retained (60 days): a later producer upsert never reopens
|
|
12
|
+
* it, and the producer's own ledger never re-sends a decided item.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs'
|
|
15
|
+
import { join, resolve } from 'node:path'
|
|
16
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
17
|
+
import { isSafeDomainName } from './domains.js'
|
|
18
|
+
import { acquireServerInstanceLock, type ServerInstanceLock } from './server-instance-lock.js'
|
|
19
|
+
|
|
20
|
+
export class WorkIntakeError extends Error {
|
|
21
|
+
constructor(readonly code: string, readonly status = 409) { super(code) }
|
|
22
|
+
}
|
|
23
|
+
export type IntakeKind = 'link' | 'ask'
|
|
24
|
+
export type IntakeStatus = 'suggested' | 'review' | 'ask' | 'accepted' | 'dismissed'
|
|
25
|
+
export interface IntakeMeeting { domain: string; month: string; filename: string; recordId: string; title: string }
|
|
26
|
+
export interface IntakeTask { domain: string; id: string; workIdentity: string; text: string }
|
|
27
|
+
export interface IntakePull { kind: 'session'; id: string; title: string; p: number }
|
|
28
|
+
export interface IntakeResolution { action: 'accept' | 'dismiss'; by: 'producer' | 'user'; at: string; taskId?: string }
|
|
29
|
+
/** Why an item of Miles's landed in Review instead of becoming a card. */
|
|
30
|
+
export type IntakeReason = 'unclear_task' | 'owner_uncertain' | 'outside_window'
|
|
31
|
+
const REASONS: ReadonlySet<string> = new Set<IntakeReason>(['unclear_task', 'owner_uncertain', 'outside_window'])
|
|
32
|
+
export interface WorkIntakeItem {
|
|
33
|
+
id: string; kind: IntakeKind; status: IntakeStatus; meeting: IntakeMeeting; confidence: number; model: string
|
|
34
|
+
producedAt: string; updatedAt: string
|
|
35
|
+
task?: IntakeTask; relation?: 'originated' | 'advanced'
|
|
36
|
+
text?: string; owner?: string | null; ownerIsMiles?: boolean; pull?: IntakePull | null; reason?: IntakeReason
|
|
37
|
+
resolution?: IntakeResolution
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const INTAKE_LIMITS = { items: 5_000, batch: 200, text: 600, title: 300, owner: 80, model: 64, retainResolvedDays: 60 } as const
|
|
41
|
+
const OPEN: ReadonlySet<IntakeStatus> = new Set(['suggested', 'review', 'ask'])
|
|
42
|
+
const ID = /^wi_[a-f0-9]{32}$/
|
|
43
|
+
const TASK_ID = /^[a-f0-9]{12}$/
|
|
44
|
+
const MONTH = /^\d{4}-(0[1-9]|1[0-2])$/
|
|
45
|
+
const clean = (v: unknown, max: number): string | null =>
|
|
46
|
+
typeof v === 'string' && v.trim() && v.length <= max && !/[\x00-\x08\x0b-\x1f\x7f]/.test(v) ? v.replace(/\s+/g, ' ').trim() : null
|
|
47
|
+
const iso = (v: unknown): string | null => typeof v === 'string' && v.length <= 40 && !Number.isNaN(Date.parse(v)) ? v : null
|
|
48
|
+
|
|
49
|
+
function meetingOf(raw: unknown): IntakeMeeting {
|
|
50
|
+
const m = raw as Partial<IntakeMeeting> | null
|
|
51
|
+
const domain = clean(m?.domain, 64), month = clean(m?.month, 7), filename = clean(m?.filename, 255)
|
|
52
|
+
const recordId = clean(m?.recordId, 512), title = clean(m?.title, INTAKE_LIMITS.title)
|
|
53
|
+
if (!domain || !isSafeDomainName(domain) || !month || !MONTH.test(month) || !filename || !/^[^/\\]+\.md$/.test(filename) || !recordId || !title) {
|
|
54
|
+
throw new WorkIntakeError('invalid_intake_meeting', 400)
|
|
55
|
+
}
|
|
56
|
+
return { domain, month, filename, recordId, title }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Validate one producer or journal record. Unknown fields are dropped, never trusted. */
|
|
60
|
+
export function parseIntakeItem(raw: unknown, now = new Date().toISOString()): WorkIntakeItem {
|
|
61
|
+
const r = raw as Record<string, unknown> | null
|
|
62
|
+
if (!r || typeof r !== 'object' || Array.isArray(r)) throw new WorkIntakeError('invalid_intake_item', 400)
|
|
63
|
+
const id = typeof r.id === 'string' && ID.test(r.id) ? r.id : null
|
|
64
|
+
const kind = r.kind === 'link' || r.kind === 'ask' ? r.kind : null
|
|
65
|
+
const status = typeof r.status === 'string' && ['suggested', 'review', 'ask', 'accepted', 'dismissed'].includes(r.status) ? r.status as IntakeStatus : null
|
|
66
|
+
const confidence = typeof r.confidence === 'number' && r.confidence >= 0 && r.confidence <= 1 ? r.confidence : null
|
|
67
|
+
const model = clean(r.model, INTAKE_LIMITS.model)
|
|
68
|
+
if (!id || !kind || !status || confidence === null || !model) throw new WorkIntakeError('invalid_intake_item', 400)
|
|
69
|
+
const allowed: readonly IntakeStatus[] = kind === 'link' ? ['suggested', 'accepted', 'dismissed'] : ['review', 'ask', 'accepted', 'dismissed']
|
|
70
|
+
if (!allowed.includes(status)) throw new WorkIntakeError('invalid_intake_status', 400)
|
|
71
|
+
const item: WorkIntakeItem = { id, kind, status, meeting: meetingOf(r.meeting), confidence, model,
|
|
72
|
+
producedAt: iso(r.producedAt) ?? now, updatedAt: iso(r.updatedAt) ?? now }
|
|
73
|
+
if (kind === 'link') {
|
|
74
|
+
const t = r.task as Partial<IntakeTask> | null
|
|
75
|
+
const domain = clean(t?.domain, 64), tid = clean(t?.id, 12), identity = clean(t?.workIdentity, 12), text = clean(t?.text, INTAKE_LIMITS.text)
|
|
76
|
+
if (!domain || !isSafeDomainName(domain) || !tid || !TASK_ID.test(tid) || !identity || !TASK_ID.test(identity) || !text) {
|
|
77
|
+
throw new WorkIntakeError('invalid_intake_task', 400)
|
|
78
|
+
}
|
|
79
|
+
item.task = { domain, id: tid, workIdentity: identity, text }
|
|
80
|
+
item.relation = r.relation === 'originated' ? 'originated' : 'advanced'
|
|
81
|
+
} else {
|
|
82
|
+
const text = clean(r.text, INTAKE_LIMITS.text)
|
|
83
|
+
if (!text) throw new WorkIntakeError('invalid_intake_text', 400)
|
|
84
|
+
item.text = text
|
|
85
|
+
item.owner = r.owner === null || r.owner === undefined ? null : clean(r.owner, INTAKE_LIMITS.owner)
|
|
86
|
+
item.ownerIsMiles = r.ownerIsMiles === true
|
|
87
|
+
// Advisory, like `reason`: a malformed pull is dropped and the ask itself still arrives.
|
|
88
|
+
const p = r.pull as Partial<IntakePull> | null | undefined
|
|
89
|
+
const pid = clean(p?.id, 200), title = clean(p?.title, 120)
|
|
90
|
+
item.pull = p && p.kind === 'session' && pid && title && typeof p.p === 'number' && p.p >= 0 && p.p <= 1 ? { kind: 'session', id: pid, title, p: p.p } : null
|
|
91
|
+
// Optional and advisory: an unknown reason from a newer producer is dropped, never a reason to refuse the item.
|
|
92
|
+
if (typeof r.reason === 'string' && REASONS.has(r.reason)) item.reason = r.reason as IntakeReason
|
|
93
|
+
}
|
|
94
|
+
const res = r.resolution as Partial<IntakeResolution> | undefined
|
|
95
|
+
if (res !== undefined) {
|
|
96
|
+
if ((res.action !== 'accept' && res.action !== 'dismiss') || (res.by !== 'producer' && res.by !== 'user')) throw new WorkIntakeError('invalid_intake_resolution', 400)
|
|
97
|
+
const taskId = res.taskId === undefined ? undefined : clean(res.taskId, 12)
|
|
98
|
+
if (taskId !== undefined && (!taskId || !TASK_ID.test(taskId))) throw new WorkIntakeError('invalid_intake_resolution', 400)
|
|
99
|
+
item.resolution = { action: res.action, by: res.by, at: iso(res.at) ?? now, ...(taskId ? { taskId } : {}) }
|
|
100
|
+
}
|
|
101
|
+
if ((item.status === 'accepted' || item.status === 'dismissed') !== (item.resolution !== undefined)) throw new WorkIntakeError('invalid_intake_resolution', 400)
|
|
102
|
+
return item
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface IntakeRejection { id: string | null; code: string }
|
|
106
|
+
export interface UpsertResult { inserted: number; updated: number; sticky: number; rejected: IntakeRejection[] }
|
|
107
|
+
|
|
108
|
+
export class WorkIntakeStore {
|
|
109
|
+
private rows: WorkIntakeItem[] = []
|
|
110
|
+
private quarantined: unknown[] = []
|
|
111
|
+
private readonly path: string
|
|
112
|
+
private readonly lock: ServerInstanceLock
|
|
113
|
+
private closed = false
|
|
114
|
+
private readonly busy = new Set<string>()
|
|
115
|
+
constructor(root: string, private readonly now: () => Date = () => new Date()) {
|
|
116
|
+
const dir = resolve(root)
|
|
117
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
118
|
+
const stat = lstatSync(dir)
|
|
119
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o077)) throw new WorkIntakeError('intake_store_unsafe', 503)
|
|
120
|
+
this.path = join(dir, 'intake.json')
|
|
121
|
+
try { this.lock = acquireServerInstanceLock({ lockDir: join(dir, 'writer.lock') }) }
|
|
122
|
+
catch { throw new WorkIntakeError('intake_store_locked', 503) }
|
|
123
|
+
if (!existsSync(this.path)) return
|
|
124
|
+
try {
|
|
125
|
+
const file = lstatSync(this.path)
|
|
126
|
+
if (!file.isFile() || file.isSymbolicLink() || file.uid !== process.getuid?.() || (file.mode & 0o077) || file.size > 32_000_000) throw new Error('unsafe')
|
|
127
|
+
const journal = JSON.parse(readFileSync(this.path, 'utf8'))
|
|
128
|
+
if (journal?.version !== 1 || !Array.isArray(journal.items)) throw new Error('version')
|
|
129
|
+
const seen = new Set<string>()
|
|
130
|
+
for (const raw of journal.items) {
|
|
131
|
+
try {
|
|
132
|
+
const item = parseIntakeItem(raw)
|
|
133
|
+
if (seen.has(item.id)) throw new Error('duplicate')
|
|
134
|
+
seen.add(item.id); this.rows.push(item)
|
|
135
|
+
} catch { this.quarantined.push(raw) } // one bad record never takes the store down
|
|
136
|
+
}
|
|
137
|
+
if (Array.isArray(journal.quarantined)) this.quarantined.push(...journal.quarantined.slice(0, 500))
|
|
138
|
+
if (this.quarantined.length) console.warn(`[work-intake] ${this.quarantined.length} unreadable journal record(s) set aside in ${this.path}`)
|
|
139
|
+
} catch { this.close(); throw new WorkIntakeError('intake_store_recovery_required', 503) }
|
|
140
|
+
}
|
|
141
|
+
list(): WorkIntakeItem[] { return structuredClone(this.rows) }
|
|
142
|
+
open(): WorkIntakeItem[] { return this.list().filter(r => OPEN.has(r.status)) }
|
|
143
|
+
get(id: string): WorkIntakeItem | undefined { return this.list().find(r => r.id === id) }
|
|
144
|
+
quarantineCount(): number { return this.quarantined.length }
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Validate per item: one bad item is answered in `rejected` and never blocks the rest of the batch, so a
|
|
148
|
+
* producer queue cannot wedge on a single record. Only a malformed batch as a whole is refused outright.
|
|
149
|
+
*/
|
|
150
|
+
upsert(raws: unknown[]): UpsertResult {
|
|
151
|
+
if (!Array.isArray(raws) || raws.length > INTAKE_LIMITS.batch) throw new WorkIntakeError('invalid_intake_batch', 400)
|
|
152
|
+
const at = this.now().toISOString()
|
|
153
|
+
const items: WorkIntakeItem[] = [], result: UpsertResult = { inserted: 0, updated: 0, sticky: 0, rejected: [] }
|
|
154
|
+
for (const raw of raws) {
|
|
155
|
+
const rawId = (raw as { id?: unknown } | null)?.id
|
|
156
|
+
const id = typeof rawId === 'string' && ID.test(rawId) ? rawId : null
|
|
157
|
+
try {
|
|
158
|
+
const item = parseIntakeItem(raw, at)
|
|
159
|
+
if (item.resolution?.by === 'user') throw new WorkIntakeError('producer_cannot_record_user_decisions', 400)
|
|
160
|
+
items.push(item)
|
|
161
|
+
} catch (e) {
|
|
162
|
+
result.rejected.push({ id, code: e instanceof WorkIntakeError ? e.code : 'invalid_intake_item' })
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (!items.length) return result
|
|
166
|
+
const next = this.list()
|
|
167
|
+
for (const item of items) {
|
|
168
|
+
const index = next.findIndex(r => r.id === item.id)
|
|
169
|
+
if (index < 0) { next.unshift({ ...item, updatedAt: at }); result.inserted++; continue }
|
|
170
|
+
const prior = next[index]
|
|
171
|
+
if (prior.resolution?.by === 'user' || (prior.status === 'accepted' && item.status !== 'accepted')) { result.sticky++; continue }
|
|
172
|
+
next[index] = { ...item, producedAt: prior.producedAt, updatedAt: at }
|
|
173
|
+
result.updated++
|
|
174
|
+
}
|
|
175
|
+
this.write(next)
|
|
176
|
+
return result
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Serialize resolution per item: a double click gets 409 instead of a second link or card. */
|
|
180
|
+
async resolve(id: string, fn: (item: WorkIntakeItem) => Promise<{ taskId?: string; action: 'accept' | 'dismiss' }>): Promise<WorkIntakeItem> {
|
|
181
|
+
if (!ID.test(id)) throw new WorkIntakeError('invalid_intake_id', 400)
|
|
182
|
+
if (this.busy.has(id)) throw new WorkIntakeError('intake_busy', 409)
|
|
183
|
+
const current = this.get(id)
|
|
184
|
+
if (!current) throw new WorkIntakeError('intake_not_found', 404)
|
|
185
|
+
if (!OPEN.has(current.status)) return current // repeat accept or dismiss: already decided, idempotent
|
|
186
|
+
this.busy.add(id)
|
|
187
|
+
try {
|
|
188
|
+
const outcome = await fn(current)
|
|
189
|
+
const at = this.now().toISOString()
|
|
190
|
+
const next = this.list(), index = next.findIndex(r => r.id === id)
|
|
191
|
+
if (index < 0) throw new WorkIntakeError('intake_not_found', 404)
|
|
192
|
+
next[index] = { ...next[index], status: outcome.action === 'accept' ? 'accepted' : 'dismissed', updatedAt: at,
|
|
193
|
+
resolution: { action: outcome.action, by: 'user', at, ...(outcome.taskId ? { taskId: outcome.taskId } : {}) } }
|
|
194
|
+
this.write(next)
|
|
195
|
+
return structuredClone(next[index])
|
|
196
|
+
} finally { this.busy.delete(id) }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private write(rows: WorkIntakeItem[]): void {
|
|
200
|
+
if (this.closed) throw new WorkIntakeError('intake_store_closed', 503)
|
|
201
|
+
const cutoff = this.now().getTime() - INTAKE_LIMITS.retainResolvedDays * 86_400_000
|
|
202
|
+
const kept = rows.filter(r => OPEN.has(r.status) || Date.parse(r.updatedAt) >= cutoff)
|
|
203
|
+
if (kept.length > INTAKE_LIMITS.items) throw new WorkIntakeError('intake_store_full', 503)
|
|
204
|
+
durableAtomicWriteFileSync(this.path, JSON.stringify({ version: 1, items: kept, quarantined: this.quarantined }) + '\n')
|
|
205
|
+
this.rows = kept
|
|
206
|
+
}
|
|
207
|
+
close(): void {
|
|
208
|
+
if (this.closed) return
|
|
209
|
+
this.closed = true
|
|
210
|
+
this.lock.release()
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Optional: a locked or unreadable journal must never keep the base API from starting. */
|
|
215
|
+
export function createOptionalWorkIntakeStore(create: () => WorkIntakeStore): WorkIntakeStore | null {
|
|
216
|
+
try { return create() }
|
|
217
|
+
catch (error) {
|
|
218
|
+
console.error('[work-intake] store unavailable; base API remains available:', error instanceof WorkIntakeError ? error.code : error)
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jev key management (COS Control Settings) and Work session recommendations. Auth comes from the parent /api mount.
|
|
3
|
+
*
|
|
4
|
+
* The key is push-only, like /api/openai-key: validated live against TypeSafe's free model listing, saved to
|
|
5
|
+
* data/jev-key.json (0600), and never returned to any client. The saved key wins over TYPESAFE_API_KEY in the
|
|
6
|
+
* environment (see resolveJevKey); /status says which source is active so Control can show it honestly.
|
|
7
|
+
*/
|
|
8
|
+
import { Router } from 'express'
|
|
9
|
+
import { existsSync, unlinkSync } from 'node:fs'
|
|
10
|
+
import { JEV_KEY_FILE, saveJevKey, sharedJevClient, validateJevKey, type JevClient } from '../lib/jev.js'
|
|
11
|
+
import { SessionRecommender, parseCandidates } from '../lib/session-recommendation.js'
|
|
12
|
+
import { listBoard } from '../lib/task-store.js'
|
|
13
|
+
import { isSafeDomainName } from '../lib/domains.js'
|
|
14
|
+
|
|
15
|
+
export interface JevRouteDependencies {
|
|
16
|
+
jev: JevClient
|
|
17
|
+
recommender: Pick<SessionRecommender, 'recommend'>
|
|
18
|
+
list: typeof listBoard
|
|
19
|
+
validate: typeof validateJevKey
|
|
20
|
+
save: typeof saveJevKey
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}): Router {
|
|
24
|
+
const jev = overrides.jev ?? sharedJevClient()
|
|
25
|
+
const deps: JevRouteDependencies = { jev, recommender: new SessionRecommender(jev), list: listBoard, validate: validateJevKey, save: saveJevKey, ...overrides }
|
|
26
|
+
const router = Router()
|
|
27
|
+
router.use(['/jev-key', '/work-board/session-recommendation'], (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
28
|
+
|
|
29
|
+
router.get('/jev-key/status', (_req, res) => res.json(deps.jev.status()))
|
|
30
|
+
|
|
31
|
+
router.post('/jev-key/set', async (req, res) => {
|
|
32
|
+
const raw = req.body?.key
|
|
33
|
+
if (!req.body || typeof req.body !== 'object' || Object.keys(req.body).some(k => k !== 'key') || typeof raw !== 'string') {
|
|
34
|
+
return res.status(400).json({ error: { code: 'invalid_jev_key', message: 'Paste a TypeSafe API key.' } })
|
|
35
|
+
}
|
|
36
|
+
const key = raw.trim()
|
|
37
|
+
if (key.length < 16 || key.length > 512 || /[\s\x00-\x1f\x7f]/.test(key)) {
|
|
38
|
+
return res.status(400).json({ error: { code: 'invalid_jev_key', message: 'That does not look like a TypeSafe API key.' } })
|
|
39
|
+
}
|
|
40
|
+
const check = await deps.validate(key)
|
|
41
|
+
if (!check.ok) return res.status(400).json({ error: { code: 'jev_key_not_accepted', message: check.reason, upstreamStatus: check.status } })
|
|
42
|
+
try { deps.save(key) } catch {
|
|
43
|
+
return res.status(500).json({ error: { code: 'jev_key_not_saved', message: 'The key was valid but could not be saved. Check the server data folder.' } })
|
|
44
|
+
}
|
|
45
|
+
deps.jev.resetForNewKey()
|
|
46
|
+
return res.json({ ok: true, ...deps.jev.status() })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
router.delete('/jev-key', (_req, res) => {
|
|
50
|
+
try { if (existsSync(JEV_KEY_FILE)) unlinkSync(JEV_KEY_FILE) } catch {
|
|
51
|
+
return res.status(500).json({ error: { code: 'jev_key_not_removed', message: 'The saved key could not be removed.' } })
|
|
52
|
+
}
|
|
53
|
+
return res.json({ ok: true, ...deps.jev.status() })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
/** Continue / Fork / New for one task. The task comes from the board; the client sends only candidate sessions. */
|
|
57
|
+
router.post('/work-board/session-recommendation', async (req, res) => {
|
|
58
|
+
const body = req.body
|
|
59
|
+
const sessions = parseCandidates(body?.sessions)
|
|
60
|
+
if (!body || typeof body !== 'object' || Object.keys(body).some(k => !['domain', 'id', 'sessions'].includes(k))
|
|
61
|
+
|| typeof body.domain !== 'string' || !isSafeDomainName(body.domain) || typeof body.id !== 'string' || !/^[a-f0-9]{12}$/.test(body.id) || !sessions) {
|
|
62
|
+
return res.status(400).json({ error: { code: 'invalid_recommendation_request', message: 'Select an exact task and send at most 80 sessions.' } })
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
// Work identity first: it survives a rename, while a new card's id could equal an old identity's text hash.
|
|
66
|
+
const rows = (await deps.list()).filter(r => r.domain === body.domain)
|
|
67
|
+
const row = rows.find(r => r.workIdentity === body.id) ?? rows.find(r => r.id === body.id)
|
|
68
|
+
if (!row) return res.status(404).json({ error: { code: 'task_not_found', message: 'That task changed or was removed. Refresh Work.' } })
|
|
69
|
+
const meetings = (row.meetingRefs ?? []).map(ref => ref.title ?? '').filter(Boolean)
|
|
70
|
+
return res.json(await deps.recommender.recommend({ task: { text: row.text || row.title || '', domain: row.domain, meetings }, sessions }))
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.error('[jev] session recommendation failed:', e instanceof Error ? e.message : e)
|
|
73
|
+
return res.json({ provider: 'none', reason: 'recommendation_unavailable' }) // advice only: never block the workspace
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
return router
|
|
77
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Router } from 'express'
|
|
2
|
+
import { WorkIntakeError, WorkIntakeStore, type IntakeMeeting, type WorkIntakeItem } from '../lib/work-intake-store.js'
|
|
3
|
+
import { captureWorkItem, linkTaskMeeting, listBoard, workIntakeCapabilities, TaskBridgeError, TaskRunError,
|
|
4
|
+
type WorkMeetingRef } from '../lib/task-store.js'
|
|
5
|
+
import { resolveSavedMeetingDetail } from './meetings.js'
|
|
6
|
+
import type { MeetingDescriptor } from '../lib/work-review-store.js'
|
|
7
|
+
import type { MeetingDetail } from '../lib/meeting-store.js'
|
|
8
|
+
|
|
9
|
+
export interface WorkIntakeDependencies {
|
|
10
|
+
store: WorkIntakeStore | null
|
|
11
|
+
list: typeof listBoard
|
|
12
|
+
capabilities: typeof workIntakeCapabilities
|
|
13
|
+
link: typeof linkTaskMeeting
|
|
14
|
+
capture: typeof captureWorkItem
|
|
15
|
+
resolveMeeting: (descriptor: MeetingDescriptor) => MeetingDetail | Promise<MeetingDetail>
|
|
16
|
+
}
|
|
17
|
+
const MAX_MEETING_REFS = 8 // task_work_metadata.MAX_REFS
|
|
18
|
+
export const LINK_RACE_RETRIES = 2
|
|
19
|
+
const DUE_TAIL = /\s*[—–-]+\s*Due:[^()]*$/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The words an accepted item becomes on the board. An ask records whose item it was: the producer moves the
|
|
23
|
+
* owner's trailing tag out of the words, so it is written back here as "(from Name)", ahead of any due date.
|
|
24
|
+
*/
|
|
25
|
+
export function intakeCardText(item: Pick<WorkIntakeItem, 'text' | 'owner' | 'ownerIsMiles'>): string {
|
|
26
|
+
const text = item.text ?? '', owner = item.owner?.trim()
|
|
27
|
+
if (item.ownerIsMiles || !owner) return text
|
|
28
|
+
const due = DUE_TAIL.exec(text)
|
|
29
|
+
return due ? `${text.slice(0, due.index)} (from ${owner})${due[0]}` : `${text} (from ${owner})`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Auth is supplied by the parent /api mount. Nothing here calls a model or starts an agent. */
|
|
33
|
+
export function createWorkIntakeRouter(overrides: Partial<WorkIntakeDependencies> & Pick<WorkIntakeDependencies, 'store'>): Router {
|
|
34
|
+
const deps: WorkIntakeDependencies = { list: listBoard, capabilities: workIntakeCapabilities, link: linkTaskMeeting,
|
|
35
|
+
capture: captureWorkItem, resolveMeeting: resolveSavedMeetingDetail, ...overrides }
|
|
36
|
+
const router = Router()
|
|
37
|
+
router.use('/work-intake', (_req, res, next) => {
|
|
38
|
+
res.set('Cache-Control', 'private, no-store')
|
|
39
|
+
if (!deps.store) return res.status(503).json({ error: { code: 'work_intake_unavailable', message: 'Work intake is unavailable on this server.' } })
|
|
40
|
+
next()
|
|
41
|
+
})
|
|
42
|
+
/** Refusals are answers; anything unexpected is also logged, since Control only shows the code. */
|
|
43
|
+
const fail = (res: import('express').Response, e: unknown, where: string) => {
|
|
44
|
+
if (e instanceof WorkIntakeError) {
|
|
45
|
+
if (e.status >= 500) console.error(`[work-intake] ${where}: ${e.code}`)
|
|
46
|
+
return res.status(e.status).json({ error: { code: e.code } })
|
|
47
|
+
}
|
|
48
|
+
if (e instanceof TaskRunError) {
|
|
49
|
+
if (e.status >= 500) console.error(`[work-intake] ${where}: ${e.code}: ${e.message}`)
|
|
50
|
+
return res.status(e.status).json({ error: { code: e.code, message: e.message } })
|
|
51
|
+
}
|
|
52
|
+
if (e instanceof TaskBridgeError) return res.status(e.code === 'task_not_found' ? 404 : 409).json({ error: { code: e.code, message: e.message } })
|
|
53
|
+
console.error(`[work-intake] ${where} failed:`, e instanceof Error ? e.message : e)
|
|
54
|
+
return res.status(503).json({ error: { code: 'work_intake_unavailable', message: 'Work intake is unavailable. Refresh before trying again.' } })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
router.get('/work-intake', async (_req, res) => {
|
|
58
|
+
try {
|
|
59
|
+
const items = deps.store!.open()
|
|
60
|
+
const counts = { suggested: 0, ask: 0, review: 0 }
|
|
61
|
+
for (const item of items) counts[item.status as keyof typeof counts]++
|
|
62
|
+
res.json({ schemaVersion: 1, capabilities: await deps.capabilities(), counts, items, quarantined: deps.store!.quarantineCount() })
|
|
63
|
+
} catch (e) { fail(res, e, 'list') }
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
router.post('/work-intake/items', (req, res) => {
|
|
67
|
+
try {
|
|
68
|
+
const body = req.body
|
|
69
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => k !== 'items') || !Array.isArray(body.items)) {
|
|
70
|
+
throw new WorkIntakeError('invalid_intake_batch', 400)
|
|
71
|
+
}
|
|
72
|
+
res.json({ ok: true, ...deps.store!.upsert(body.items) })
|
|
73
|
+
} catch (e) { fail(res, e, 'upsert') }
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
router.post('/work-intake/:id/resolve', async (req, res) => {
|
|
77
|
+
try {
|
|
78
|
+
const action = req.body?.action
|
|
79
|
+
if (!req.body || typeof req.body !== 'object' || Object.keys(req.body).some(k => k !== 'action') || (action !== 'accept' && action !== 'dismiss')) {
|
|
80
|
+
throw new WorkIntakeError('invalid_intake_action', 400)
|
|
81
|
+
}
|
|
82
|
+
const item = await deps.store!.resolve(String(req.params.id), async current =>
|
|
83
|
+
action === 'dismiss' ? { action: 'dismiss' } : { action: 'accept', taskId: await accept(current) })
|
|
84
|
+
res.json({ ok: true, item })
|
|
85
|
+
} catch (e) { fail(res, e, 'resolve') }
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
/** The meeting must still be the one that was judged; its canonical title comes from the library, not the producer. */
|
|
89
|
+
async function verifiedMeeting(m: IntakeMeeting): Promise<WorkMeetingRef> {
|
|
90
|
+
const descriptor: MeetingDescriptor = { domain: m.domain, month: m.month, filename: m.filename, recordId: m.recordId }
|
|
91
|
+
let resolved: MeetingDetail
|
|
92
|
+
try { resolved = await deps.resolveMeeting(descriptor) }
|
|
93
|
+
catch { throw new WorkIntakeError('meeting_unavailable', 409) }
|
|
94
|
+
if (resolved.recordId !== m.recordId) throw new WorkIntakeError('meeting_identity_changed', 409)
|
|
95
|
+
return { ...descriptor, recordId: m.recordId, title: (resolved.title || m.title).replace(/\s+/g, ' ').trim().slice(0, 300) }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function accept(item: WorkIntakeItem): Promise<string> {
|
|
99
|
+
// The write power first: a read-only board must say so, not report its meeting as unreadable.
|
|
100
|
+
const capabilities = await deps.capabilities()
|
|
101
|
+
if (capabilities.bridgeUnavailable) throw new WorkIntakeError('task_bridge_unavailable', 503)
|
|
102
|
+
if (item.kind === 'link' && !capabilities.linkWrites) throw new WorkIntakeError('work_board_read_only', 409)
|
|
103
|
+
if (item.kind === 'ask' && !capabilities.cardCreation) throw new WorkIntakeError('card_creation_unavailable', 409)
|
|
104
|
+
const meeting = await verifiedMeeting(item.meeting)
|
|
105
|
+
if (item.kind === 'link') {
|
|
106
|
+
const task = item.task!
|
|
107
|
+
// Work identity survives a rename; the task id does not.
|
|
108
|
+
return linkRow(task.domain, r => r.workIdentity === task.workIdentity || r.id === task.id, meeting)
|
|
109
|
+
}
|
|
110
|
+
const out = await deps.capture(meeting.domain, intakeCardText(item), meeting)
|
|
111
|
+
if (out.created || out.linked) return out.id
|
|
112
|
+
// The same words are already an OPEN task: that task is this work, so link it (as the producer does).
|
|
113
|
+
// On closed, archived or delegated work, adding a second card with the same words is refused.
|
|
114
|
+
if (!out.checked && !out.archived && !out.delegated) return linkRow(meeting.domain, r => r.id === out.id, meeting)
|
|
115
|
+
throw new WorkIntakeError('intake_text_collision', 409)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Link one task to the meeting. Re-reads the row on every attempt; up to two retries on a revision race. */
|
|
119
|
+
async function linkRow(domain: string, match: (row: Awaited<ReturnType<typeof listBoard>>[number]) => boolean, meeting: WorkMeetingRef): Promise<string> {
|
|
120
|
+
for (let attempt = 0; ; attempt++) {
|
|
121
|
+
const row = (await deps.list()).find(r => r.domain === domain && match(r))
|
|
122
|
+
if (!row || !row.workRevision) throw new WorkIntakeError('intake_task_changed', 409)
|
|
123
|
+
const refs = row.meetingRefs ?? []
|
|
124
|
+
if (refs.some(ref => ref.recordId === meeting.recordId)) return row.id
|
|
125
|
+
if (refs.length >= MAX_MEETING_REFS) throw new WorkIntakeError('meeting_links_full', 409)
|
|
126
|
+
try {
|
|
127
|
+
await deps.link(row.domain, row.id, row.text, row.workRevision, meeting)
|
|
128
|
+
return row.id
|
|
129
|
+
} catch (e) {
|
|
130
|
+
if (attempt < LINK_RACE_RETRIES && e instanceof TaskBridgeError && e.code === 'task_revision_changed') continue
|
|
131
|
+
throw e
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return router
|
|
136
|
+
}
|