@adhdev/mesh-shared 1.0.82-rc.33 → 1.0.82-rc.35
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/dist/assistant-protocol.d.ts +13 -2
- package/dist/decision-push-relay.d.ts +20 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +531 -13
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +489 -13
- package/dist/index.mjs.map +1 -1
- package/dist/mesh-tool-names.d.ts +2 -2
- package/dist/mission-items.d.ts +202 -0
- package/dist/protocol/daemon-server.d.ts +33 -2
- package/dist/protocol/dashboard-server.d.ts +10 -2
- package/dist/relay-reprobe.d.ts +52 -0
- package/dist/turn-ipc-mission.d.ts +59 -1
- package/dist/worker-protocol.d.ts +1 -1
- package/package.json +1 -1
- package/src/assistant-protocol.ts +39 -2
- package/src/decision-push-relay.ts +23 -0
- package/src/index.ts +3 -0
- package/src/mesh-tool-names.ts +3 -0
- package/src/mission-items.ts +421 -0
- package/src/protocol/daemon-server.ts +32 -2
- package/src/protocol/dashboard-server.ts +7 -2
- package/src/relay-reprobe.ts +117 -0
- package/src/turn-ipc-mission.ts +108 -9
- package/src/worker-protocol.ts +11 -0
package/src/index.ts
CHANGED
|
@@ -41,6 +41,8 @@ export * from './session-launch'
|
|
|
41
41
|
export * from './turn-ipc'
|
|
42
42
|
export * from './path-ownership'
|
|
43
43
|
export * from './mission-brief'
|
|
44
|
+
export * from './mission-items'
|
|
45
|
+
export * from './decision-push-relay'
|
|
44
46
|
export * from './keyed-doc-delta'
|
|
45
47
|
export * from './provider-approval'
|
|
46
48
|
export * from './provider-question'
|
|
@@ -48,3 +50,4 @@ export * from './launch-defaults'
|
|
|
48
50
|
|
|
49
51
|
export * from './role-launch';
|
|
50
52
|
export * from './task-source-phase';
|
|
53
|
+
export * from './relay-reprobe'
|
package/src/mesh-tool-names.ts
CHANGED
|
@@ -45,6 +45,7 @@ export const CANONICAL_MESH_TOOL_NAMES = [
|
|
|
45
45
|
'mesh_list_pending_approvals',
|
|
46
46
|
'mesh_create',
|
|
47
47
|
'mesh_add_node',
|
|
48
|
+
'mesh_reattach_source_parent',
|
|
48
49
|
'mesh_attach_remote',
|
|
49
50
|
'mesh_clone_node',
|
|
50
51
|
'mesh_remove_node',
|
|
@@ -69,6 +70,8 @@ export const CANONICAL_MESH_TOOL_NAMES = [
|
|
|
69
70
|
'driver_list',
|
|
70
71
|
'driver_describe',
|
|
71
72
|
'artifact_list',
|
|
73
|
+
'artifact_read',
|
|
74
|
+
'artifact_register',
|
|
72
75
|
] as const;
|
|
73
76
|
|
|
74
77
|
export type CanonicalMeshToolName = typeof CANONICAL_MESH_TOOL_NAMES[number];
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mission-items — structured mission records: the item kinds a mission carries
|
|
3
|
+
* (remaining work, live checks, owner decisions, evidence), their normalization
|
|
4
|
+
* and limits, dedupe-key derivation and the close outcome enum
|
|
5
|
+
* (docs/design/2026-10-28-structured-missions-and-owner-decisions.md §4, §5).
|
|
6
|
+
*
|
|
7
|
+
* Philosophy (owner 2026-10-28, design §0): NEVER reject for size or shape —
|
|
8
|
+
* over-long text is truncated, extra refs are dropped, unusable items are
|
|
9
|
+
* reported per-item. The only hard rule lives elsewhere (the daemon): a
|
|
10
|
+
* coordinator or worker can never ANSWER a decision, so `answer`/`delivery`
|
|
11
|
+
* are stripped from every caller-supplied `decision` here.
|
|
12
|
+
*
|
|
13
|
+
* Dependency-free leaf: pure functions over plain data, no storage.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const MISSION_ITEM_KINDS = ['remaining', 'live_check', 'decision', 'evidence'] as const
|
|
17
|
+
export type MissionItemKind = typeof MISSION_ITEM_KINDS[number]
|
|
18
|
+
|
|
19
|
+
export const MISSION_ITEM_STATES = ['open', 'done', 'dropped', 'moved', 'answered', 'expired', 'withdrawn', 'superseded'] as const
|
|
20
|
+
export type MissionItemState = typeof MISSION_ITEM_STATES[number]
|
|
21
|
+
|
|
22
|
+
/** States a coordinator/worker may SET through items_update, per kind. `answered`/`expired` are daemon-owned. */
|
|
23
|
+
export const COORDINATOR_SETTABLE_ITEM_STATES: Readonly<Record<MissionItemKind, readonly MissionItemState[]>> = {
|
|
24
|
+
remaining: ['open', 'done', 'dropped'],
|
|
25
|
+
live_check: ['open', 'done', 'dropped'],
|
|
26
|
+
evidence: ['open', 'done', 'dropped'],
|
|
27
|
+
decision: ['open', 'withdrawn', 'superseded'],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const MISSION_OUTCOMES = ['merged', 'verified_live', 'superseded', 'not_needed', 'owner_abandoned', 'unspecified'] as const
|
|
31
|
+
export type MissionOutcome = typeof MISSION_OUTCOMES[number]
|
|
32
|
+
export function isMissionOutcome(value: unknown): value is MissionOutcome {
|
|
33
|
+
return typeof value === 'string' && (MISSION_OUTCOMES as readonly string[]).includes(value)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const MISSION_REF_KINDS = ['commit', 'branch', 'pr', 'task', 'artifact', 'mission', 'url'] as const
|
|
37
|
+
export type MissionRefKind = typeof MISSION_REF_KINDS[number]
|
|
38
|
+
|
|
39
|
+
export const MISSION_LIMITS = {
|
|
40
|
+
summary: 300,
|
|
41
|
+
outcomeReason: 500,
|
|
42
|
+
itemText: 300,
|
|
43
|
+
machine: 80,
|
|
44
|
+
refs: 8,
|
|
45
|
+
refValue: 300,
|
|
46
|
+
refRepo: 120,
|
|
47
|
+
refNote: 200,
|
|
48
|
+
dedupeKey: 400,
|
|
49
|
+
question: 300,
|
|
50
|
+
context: 600,
|
|
51
|
+
options: 5,
|
|
52
|
+
optionLabel: 60,
|
|
53
|
+
optionDetail: 200,
|
|
54
|
+
/** Open items per mission before the oldest `remaining` fold into a "N more" summary item. */
|
|
55
|
+
openItemsPerMission: 200,
|
|
56
|
+
/** One event payload (serialized). */
|
|
57
|
+
eventPayloadBytes: 16 * 1024,
|
|
58
|
+
/** items_add / items_update entries honoured per call (extra entries are reported, not applied). */
|
|
59
|
+
itemsPerCall: 50,
|
|
60
|
+
} as const
|
|
61
|
+
|
|
62
|
+
export const MISSION_DECISION_OPTION_IDS = ['a', 'b', 'c', 'd', 'e'] as const
|
|
63
|
+
export const MISSION_DECISION_URGENCIES = ['normal', 'blocking'] as const
|
|
64
|
+
export type MissionDecisionUrgency = typeof MISSION_DECISION_URGENCIES[number]
|
|
65
|
+
export const MISSION_DECISION_ON_DUE = ['remind', 'apply_recommended'] as const
|
|
66
|
+
export type MissionDecisionOnDue = typeof MISSION_DECISION_ON_DUE[number]
|
|
67
|
+
|
|
68
|
+
export interface MissionItemRef {
|
|
69
|
+
kind: MissionRefKind
|
|
70
|
+
repo?: string
|
|
71
|
+
value: string
|
|
72
|
+
note?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface MissionDecisionOption {
|
|
76
|
+
id: string
|
|
77
|
+
label: string
|
|
78
|
+
detail?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Who/where an answer came from. Written only by the daemon's (owner-side) answer path — never from tool input. */
|
|
82
|
+
export interface MissionDecisionAnswer {
|
|
83
|
+
optionId?: string
|
|
84
|
+
text?: string
|
|
85
|
+
by: 'owner' | 'auto_recommended'
|
|
86
|
+
via: 'dashboard' | 'assistant' | 'push' | 'cli' | 'auto'
|
|
87
|
+
interpretedFrom?: string
|
|
88
|
+
at: string
|
|
89
|
+
ingress?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface MissionDecisionDelivery {
|
|
93
|
+
messageId: string
|
|
94
|
+
/**
|
|
95
|
+
* `queued`: the coordinator's input queue accepted it but has not typed it yet — the daemon
|
|
96
|
+
* confirms it against the send receipt (a daemon restart drops that queue). Daemon-written.
|
|
97
|
+
*/
|
|
98
|
+
state: 'pending' | 'queued' | 'sent' | 'acked'
|
|
99
|
+
lastAttemptAt?: string
|
|
100
|
+
/** Coordinator session + funnel messageId of a `queued` delivery (receipt lookup). Daemon-written. */
|
|
101
|
+
sessionId?: string
|
|
102
|
+
wireMessageId?: string
|
|
103
|
+
/** Failed send attempts of this revision (retry backoff). Daemon-written. */
|
|
104
|
+
attempts?: number
|
|
105
|
+
/** What the coordinator is told: a first answer, a changed answer, or an undone one. Daemon-written. */
|
|
106
|
+
kind?: 'answered' | 'changed' | 'undone'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface MissionDecision {
|
|
110
|
+
question: string
|
|
111
|
+
context?: string
|
|
112
|
+
options: MissionDecisionOption[]
|
|
113
|
+
recommended?: string
|
|
114
|
+
allowFreeText: boolean
|
|
115
|
+
dueAt?: string
|
|
116
|
+
onDue: MissionDecisionOnDue
|
|
117
|
+
urgency: MissionDecisionUrgency
|
|
118
|
+
answer?: MissionDecisionAnswer
|
|
119
|
+
delivery?: MissionDecisionDelivery
|
|
120
|
+
/**
|
|
121
|
+
* Coordinator-prompt watermark (bundle B): ISO time the coordinator last "saw" this item's
|
|
122
|
+
* answer. An answered decision is shown in the coordinator prompt while this is absent or
|
|
123
|
+
* older than `answer.at` (a changed answer re-surfaces). Written only by the daemon.
|
|
124
|
+
*/
|
|
125
|
+
promptAckedAt?: string
|
|
126
|
+
/** Counts answer writes (answer / change / undo); delivery messageIds are `decision:<itemId>:r<n>`. Daemon-written. */
|
|
127
|
+
answerRevision?: number
|
|
128
|
+
/** ISO time an `onDue: remind` crossing was recorded (once per dueAt). Daemon-written. */
|
|
129
|
+
remindedAt?: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface NormalizedMissionItem {
|
|
133
|
+
kind: MissionItemKind
|
|
134
|
+
text: string
|
|
135
|
+
machine?: string
|
|
136
|
+
refs: MissionItemRef[]
|
|
137
|
+
decision?: MissionDecision
|
|
138
|
+
dedupeKey: string
|
|
139
|
+
/** Fields that were truncated/dropped — informational, never an error. */
|
|
140
|
+
truncated: string[]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function truncateMissionText(value: string, max: number): string {
|
|
144
|
+
if (value.length <= max) return value
|
|
145
|
+
return `${value.slice(0, max - 1).trimEnd()}…`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function str(value: unknown): string {
|
|
149
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function collapse(value: string): string {
|
|
153
|
+
return value.replace(/\s+/g, ' ').trim()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function normalizeMissionSummary(value: unknown): { value: string; truncated: boolean } | null {
|
|
157
|
+
if (typeof value !== 'string') return null
|
|
158
|
+
const text = collapse(value)
|
|
159
|
+
return { value: truncateMissionText(text, MISSION_LIMITS.summary), truncated: text.length > MISSION_LIMITS.summary }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function normalizeOutcomeReason(value: unknown): { value: string; truncated: boolean } | null {
|
|
163
|
+
if (typeof value !== 'string') return null
|
|
164
|
+
const text = value.trim()
|
|
165
|
+
return { value: truncateMissionText(text, MISSION_LIMITS.outcomeReason), truncated: text.length > MISSION_LIMITS.outcomeReason }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Refs: ≤8, `{kind, repo?, value, note?}`. Unusable entries are dropped; the 9th and later are dropped. */
|
|
169
|
+
export function normalizeMissionRefs(value: unknown): { refs: MissionItemRef[]; truncated: boolean } {
|
|
170
|
+
if (!Array.isArray(value)) return { refs: [], truncated: false }
|
|
171
|
+
const refs: MissionItemRef[] = []
|
|
172
|
+
let truncated = false
|
|
173
|
+
for (const raw of value) {
|
|
174
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
|
|
175
|
+
const r = raw as Record<string, unknown>
|
|
176
|
+
const kind = str(r.kind)
|
|
177
|
+
const refValue = str(r.value)
|
|
178
|
+
if (!(MISSION_REF_KINDS as readonly string[]).includes(kind) || !refValue) continue
|
|
179
|
+
if (refs.length >= MISSION_LIMITS.refs) { truncated = true; break }
|
|
180
|
+
const repo = str(r.repo)
|
|
181
|
+
const note = str(r.note)
|
|
182
|
+
refs.push({
|
|
183
|
+
kind: kind as MissionRefKind,
|
|
184
|
+
...(repo ? { repo: truncateMissionText(repo, MISSION_LIMITS.refRepo) } : {}),
|
|
185
|
+
value: truncateMissionText(refValue, MISSION_LIMITS.refValue),
|
|
186
|
+
...(note ? { note: truncateMissionText(note, MISSION_LIMITS.refNote) } : {}),
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
return { refs, truncated }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeOptions(value: unknown, truncated: string[]): MissionDecisionOption[] {
|
|
193
|
+
if (!Array.isArray(value)) return []
|
|
194
|
+
const out: MissionDecisionOption[] = []
|
|
195
|
+
for (const raw of value) {
|
|
196
|
+
if (typeof raw === 'string' && raw.trim()) {
|
|
197
|
+
if (out.length >= MISSION_LIMITS.options) { truncated.push('options'); break }
|
|
198
|
+
out.push({ id: MISSION_DECISION_OPTION_IDS[out.length], label: truncateMissionText(raw.trim(), MISSION_LIMITS.optionLabel) })
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
|
|
202
|
+
const o = raw as Record<string, unknown>
|
|
203
|
+
const label = collapse(str(o.label))
|
|
204
|
+
if (!label) continue
|
|
205
|
+
if (out.length >= MISSION_LIMITS.options) { truncated.push('options'); break }
|
|
206
|
+
const detail = str(o.detail)
|
|
207
|
+
// Ids are positional ('a'..'e') so a caller cannot collide or invent ids.
|
|
208
|
+
out.push({
|
|
209
|
+
id: MISSION_DECISION_OPTION_IDS[out.length],
|
|
210
|
+
label: truncateMissionText(label, MISSION_LIMITS.optionLabel),
|
|
211
|
+
...(detail ? { detail: truncateMissionText(detail, MISSION_LIMITS.optionDetail) } : {}),
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
return out
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function normalizeDueAt(value: unknown): string | undefined {
|
|
218
|
+
if (typeof value !== 'string' || !value.trim()) return undefined
|
|
219
|
+
const ms = Date.parse(value)
|
|
220
|
+
return Number.isNaN(ms) ? undefined : new Date(ms).toISOString()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Caller-supplied decision → stored shape. `answer`/`delivery` (and any other
|
|
225
|
+
* unknown key) are DROPPED: a coordinator/worker cannot pre-answer its own
|
|
226
|
+
* question. `recommended` must name one of the options, else it is dropped.
|
|
227
|
+
*/
|
|
228
|
+
export function normalizeDecisionInput(raw: Record<string, unknown>, truncated: string[]): MissionDecision | null {
|
|
229
|
+
const questionSource = collapse(str(raw.question) || str(raw.text))
|
|
230
|
+
if (!questionSource) return null
|
|
231
|
+
if (questionSource.length > MISSION_LIMITS.question) truncated.push('question')
|
|
232
|
+
const question = truncateMissionText(questionSource, MISSION_LIMITS.question)
|
|
233
|
+
const contextSource = str(raw.context)
|
|
234
|
+
if (contextSource.length > MISSION_LIMITS.context) truncated.push('context')
|
|
235
|
+
const options = normalizeOptions(raw.options, truncated)
|
|
236
|
+
const recommendedRaw = str(raw.recommended)
|
|
237
|
+
const recommended = recommendedRaw && options.some(o => o.id === recommendedRaw)
|
|
238
|
+
? recommendedRaw
|
|
239
|
+
// Also accept the option label (coordinators naturally write "recommended: Option A").
|
|
240
|
+
: options.find(o => recommendedRaw && o.label.toLowerCase() === recommendedRaw.toLowerCase())?.id
|
|
241
|
+
const dueAt = normalizeDueAt(raw.dueAt ?? raw.due_at)
|
|
242
|
+
const onDueRaw = str(raw.onDue ?? raw.on_due)
|
|
243
|
+
const urgencyRaw = str(raw.urgency)
|
|
244
|
+
const allowFreeTextRaw = raw.allowFreeText ?? raw.allow_free_text
|
|
245
|
+
return {
|
|
246
|
+
question,
|
|
247
|
+
...(contextSource ? { context: truncateMissionText(contextSource, MISSION_LIMITS.context) } : {}),
|
|
248
|
+
options,
|
|
249
|
+
...(recommended ? { recommended } : {}),
|
|
250
|
+
allowFreeText: typeof allowFreeTextRaw === 'boolean' ? allowFreeTextRaw : true,
|
|
251
|
+
...(dueAt ? { dueAt } : {}),
|
|
252
|
+
// apply_recommended without a recommended option has nothing to apply — fall back to remind.
|
|
253
|
+
onDue: onDueRaw === 'apply_recommended' && recommended ? 'apply_recommended' : 'remind',
|
|
254
|
+
urgency: urgencyRaw === 'blocking' ? 'blocking' : 'normal',
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Normalized text for dedupe: lower-case, whitespace-collapsed, trailing punctuation trimmed. */
|
|
259
|
+
export function normalizeForDedupe(text: string): string {
|
|
260
|
+
return collapse(text).toLowerCase().replace(/[\s.!?。]+$/u, '')
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function deriveItemDedupeKey(kind: MissionItemKind, parts: { text?: string; machine?: string; refs?: readonly MissionItemRef[]; question?: string; explicit?: string }): string {
|
|
264
|
+
const explicit = str(parts.explicit)
|
|
265
|
+
if (explicit) return truncateMissionText(`k:${explicit}`, MISSION_LIMITS.dedupeKey)
|
|
266
|
+
if (kind === 'decision') return truncateMissionText(`decision:${normalizeForDedupe(parts.question ?? parts.text ?? '')}`, MISSION_LIMITS.dedupeKey)
|
|
267
|
+
if (kind === 'evidence') {
|
|
268
|
+
const ref = parts.refs?.[0]
|
|
269
|
+
if (ref) return truncateMissionText(`evidence:${ref.kind}:${ref.repo ?? ''}:${ref.value}`, MISSION_LIMITS.dedupeKey)
|
|
270
|
+
}
|
|
271
|
+
const machine = parts.machine ? `@${normalizeForDedupe(parts.machine)}` : ''
|
|
272
|
+
return truncateMissionText(`${kind}${machine}:${normalizeForDedupe(parts.text ?? '')}`, MISSION_LIMITS.dedupeKey)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Normalize one caller-supplied `items_add[]` entry. Returns `null` + `error`
|
|
277
|
+
* only when nothing usable remains (no text/question) — everything else is
|
|
278
|
+
* clamped, never rejected.
|
|
279
|
+
*/
|
|
280
|
+
export function normalizeMissionItemInput(raw: unknown): { item: NormalizedMissionItem } | { error: string } {
|
|
281
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { error: 'item must be an object' }
|
|
282
|
+
const r = raw as Record<string, unknown>
|
|
283
|
+
const kind = str(r.kind) || 'remaining'
|
|
284
|
+
if (!(MISSION_ITEM_KINDS as readonly string[]).includes(kind)) {
|
|
285
|
+
return { error: `invalid kind '${kind}' (valid: ${MISSION_ITEM_KINDS.join(', ')})` }
|
|
286
|
+
}
|
|
287
|
+
const truncated: string[] = []
|
|
288
|
+
const { refs, truncated: refsTruncated } = normalizeMissionRefs(r.refs)
|
|
289
|
+
if (refsTruncated) truncated.push('refs')
|
|
290
|
+
const machineRaw = collapse(str(r.machine))
|
|
291
|
+
const machine = machineRaw ? truncateMissionText(machineRaw, MISSION_LIMITS.machine) : undefined
|
|
292
|
+
const explicitKey = str(r.dedupe_key ?? r.dedupeKey)
|
|
293
|
+
if (kind === 'decision') {
|
|
294
|
+
const decisionRaw = (r.decision && typeof r.decision === 'object' && !Array.isArray(r.decision) ? r.decision : r) as Record<string, unknown>
|
|
295
|
+
const decision = normalizeDecisionInput({ ...decisionRaw, question: decisionRaw.question ?? r.question ?? r.text }, truncated)
|
|
296
|
+
if (!decision) return { error: 'decision item needs a question' }
|
|
297
|
+
return {
|
|
298
|
+
item: {
|
|
299
|
+
kind,
|
|
300
|
+
text: decision.question,
|
|
301
|
+
...(machine ? { machine } : {}),
|
|
302
|
+
refs,
|
|
303
|
+
decision,
|
|
304
|
+
dedupeKey: deriveItemDedupeKey('decision', { question: decision.question, explicit: explicitKey }),
|
|
305
|
+
truncated,
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const textSource = collapse(str(r.text))
|
|
310
|
+
if (!textSource && !(kind === 'evidence' && refs.length > 0)) return { error: `${kind} item needs text` }
|
|
311
|
+
if (textSource.length > MISSION_LIMITS.itemText) truncated.push('text')
|
|
312
|
+
const text = truncateMissionText(textSource || `${refs[0].kind}: ${refs[0].value}`, MISSION_LIMITS.itemText)
|
|
313
|
+
return {
|
|
314
|
+
item: {
|
|
315
|
+
kind: kind as MissionItemKind,
|
|
316
|
+
text,
|
|
317
|
+
...(machine ? { machine } : {}),
|
|
318
|
+
refs,
|
|
319
|
+
dedupeKey: deriveItemDedupeKey(kind as MissionItemKind, { text, machine, refs, explicit: explicitKey }),
|
|
320
|
+
truncated,
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export interface MissionItemUpdateInput {
|
|
326
|
+
id: string
|
|
327
|
+
state?: MissionItemState
|
|
328
|
+
text?: string
|
|
329
|
+
machine?: string | null
|
|
330
|
+
refs?: MissionItemRef[]
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Normalize one `items_update[]` entry (id required). The kind-specific state rule is applied by the daemon, which knows the stored kind. */
|
|
334
|
+
export function normalizeMissionItemUpdate(raw: unknown): { update: MissionItemUpdateInput; truncated: string[] } | { error: string } {
|
|
335
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { error: 'update must be an object' }
|
|
336
|
+
const r = raw as Record<string, unknown>
|
|
337
|
+
const id = str(r.id ?? r.item_id ?? r.itemId)
|
|
338
|
+
if (!id) return { error: 'update needs an item id' }
|
|
339
|
+
const truncated: string[] = []
|
|
340
|
+
const update: MissionItemUpdateInput = { id }
|
|
341
|
+
if (r.state !== undefined) {
|
|
342
|
+
const state = str(r.state)
|
|
343
|
+
if (!(MISSION_ITEM_STATES as readonly string[]).includes(state)) return { error: `invalid state '${state}'` }
|
|
344
|
+
update.state = state as MissionItemState
|
|
345
|
+
}
|
|
346
|
+
if (typeof r.text === 'string') {
|
|
347
|
+
const text = collapse(r.text)
|
|
348
|
+
if (text.length > MISSION_LIMITS.itemText) truncated.push('text')
|
|
349
|
+
if (text) update.text = truncateMissionText(text, MISSION_LIMITS.itemText)
|
|
350
|
+
}
|
|
351
|
+
if (r.machine === null) update.machine = null
|
|
352
|
+
else if (typeof r.machine === 'string') {
|
|
353
|
+
const machine = collapse(r.machine)
|
|
354
|
+
update.machine = machine ? truncateMissionText(machine, MISSION_LIMITS.machine) : null
|
|
355
|
+
}
|
|
356
|
+
if (r.refs !== undefined) {
|
|
357
|
+
const { refs, truncated: t } = normalizeMissionRefs(r.refs)
|
|
358
|
+
if (t) truncated.push('refs')
|
|
359
|
+
update.refs = refs
|
|
360
|
+
}
|
|
361
|
+
return { update, truncated }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Cap a serialized event payload at {@link MISSION_LIMITS.eventPayloadBytes}; over-size payloads become a stub that says so. */
|
|
365
|
+
export function boundEventPayload(payload: Record<string, unknown>): string {
|
|
366
|
+
let json = JSON.stringify(payload)
|
|
367
|
+
if (json.length <= MISSION_LIMITS.eventPayloadBytes) return json
|
|
368
|
+
// Shrink the largest string fields first (the previous goal text is the usual offender).
|
|
369
|
+
const shrunk: Record<string, unknown> = {}
|
|
370
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
371
|
+
shrunk[k] = typeof v === 'string' && v.length > 4000 ? `${v.slice(0, 4000)}…` : v
|
|
372
|
+
}
|
|
373
|
+
shrunk.payloadTruncated = true
|
|
374
|
+
json = JSON.stringify(shrunk)
|
|
375
|
+
if (json.length <= MISSION_LIMITS.eventPayloadBytes) return json
|
|
376
|
+
return JSON.stringify({ payloadTruncated: true, keys: Object.keys(payload).slice(0, 20) })
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The carry-over mission's fixed title (design §5.1). */
|
|
380
|
+
export const CARRY_OVER_MISSION_TITLE = 'Carry-over — open items from closed missions'
|
|
381
|
+
/** The title earlier builds stored; existing DBs still hold it, so it keeps matching (no rename). */
|
|
382
|
+
export const LEGACY_CARRY_OVER_MISSION_TITLE = '이월 — 닫힌 미션에서 넘어온 항목'
|
|
383
|
+
/** Canonical goal text of a newly created carry-over mission. */
|
|
384
|
+
export const CARRY_OVER_MISSION_GOAL = 'Open items carried over from closed missions, and decisions requested without a mission. Managed automatically — do not close.'
|
|
385
|
+
/**
|
|
386
|
+
* THE one predicate for "is this the auto carry-over mission". The daemon's `is_carry_over`
|
|
387
|
+
* column (`isCarryOver`) is the source of truth; wire views that cannot carry the flag (older
|
|
388
|
+
* strict decoders reject unknown keys) fall back to the fixed title. Use this everywhere.
|
|
389
|
+
*/
|
|
390
|
+
export function isCarryOverMissionRecord(m: { isCarryOver?: boolean; title?: unknown } | null | undefined): boolean {
|
|
391
|
+
return !!m && (m.isCarryOver === true || isCarryOverMissionTitle(m.title))
|
|
392
|
+
}
|
|
393
|
+
/** The carry-over mission's title is fixed (the daemon ignores renames), so a title match identifies it on wire views that carry no flag. */
|
|
394
|
+
export function isCarryOverMissionTitle(title: unknown): boolean {
|
|
395
|
+
if (typeof title !== 'string') return false
|
|
396
|
+
const t = title.trim()
|
|
397
|
+
return t === CARRY_OVER_MISSION_TITLE || t === LEGACY_CARRY_OVER_MISSION_TITLE
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Prefix of the owner-confirmation decision a coordinator's `owner_abandoned` close creates. */
|
|
401
|
+
export const OWNER_ABANDON_CONFIRM_KEY_PREFIX = 'owner-abandon-confirm:'
|
|
402
|
+
|
|
403
|
+
export const OWNER_ABANDON_CONFIRM_QUESTION = "The coordinator closed this as 'owner abandoned' — correct?"
|
|
404
|
+
export const OWNER_ABANDON_CONFIRM_OPTIONS = ['Correct', 'Reopen'] as const
|
|
405
|
+
|
|
406
|
+
/** worker `report_completion.decisions_needed` cap. */
|
|
407
|
+
export const WORKER_DECISIONS_NEEDED_MAX = 3
|
|
408
|
+
/** worker `report_completion.commits` cap and shape. */
|
|
409
|
+
export const WORKER_COMMITS_MAX = 20
|
|
410
|
+
const SHA_RE = /^[0-9a-f]{7,64}$/i
|
|
411
|
+
export function normalizeReportCommits(value: unknown): string[] | null {
|
|
412
|
+
if (!Array.isArray(value)) return null
|
|
413
|
+
const out: string[] = []
|
|
414
|
+
for (const v of value) {
|
|
415
|
+
if (typeof v !== 'string') return null
|
|
416
|
+
const sha = v.trim()
|
|
417
|
+
if (!SHA_RE.test(sha)) return null
|
|
418
|
+
if (!out.includes(sha.toLowerCase())) out.push(sha.toLowerCase())
|
|
419
|
+
}
|
|
420
|
+
return out
|
|
421
|
+
}
|
|
@@ -52,6 +52,9 @@ export const DAEMON_STATUS_EVENT_NAMES = [
|
|
|
52
52
|
'agent:generating_completed',
|
|
53
53
|
'agent:stopped',
|
|
54
54
|
'monitor:no_progress',
|
|
55
|
+
// An owner decision (structured missions) needs the owner. Server-bound for the push only;
|
|
56
|
+
// the dashboard learns about decisions over P2P (`mission_decisions_list`).
|
|
57
|
+
'agent:decision_needed',
|
|
55
58
|
] as const
|
|
56
59
|
export type DaemonStatusEventName = typeof DAEMON_STATUS_EVENT_NAMES[number]
|
|
57
60
|
export const isDaemonStatusEventName = makeTypeGuard(DAEMON_STATUS_EVENT_NAMES)
|
|
@@ -62,6 +65,27 @@ export const isDaemonStatusEventName = makeTypeGuard(DAEMON_STATUS_EVENT_NAMES)
|
|
|
62
65
|
* (`modalMessage`/`modalButtons`, CLAUDE.md "Push notification exception").
|
|
63
66
|
* Mirror of daemon-core `DaemonStatusEventPayload`.
|
|
64
67
|
*/
|
|
68
|
+
/**
|
|
69
|
+
* Owner-decision push payload (design §9, owner decision D1 2026-10-28). The ONLY mission text that
|
|
70
|
+
* reaches the server: the question (<=80 chars) and up to 3 option labels (<=24 chars). Never
|
|
71
|
+
* `context`, free-text answers, mission goal/summary or chat content.
|
|
72
|
+
*/
|
|
73
|
+
export type DecisionPushWire = {
|
|
74
|
+
meshId: string
|
|
75
|
+
missionId: string
|
|
76
|
+
itemId: string
|
|
77
|
+
question: string
|
|
78
|
+
options: Array<{ id: string; label: string }>
|
|
79
|
+
recommended?: string
|
|
80
|
+
urgency: 'normal' | 'blocking'
|
|
81
|
+
dueAt?: string
|
|
82
|
+
/** `new` = freshly asked; `remind` = a blocking decision crossed its due time. */
|
|
83
|
+
reason: 'new' | 'remind'
|
|
84
|
+
}
|
|
85
|
+
export const DECISION_PUSH_QUESTION_MAX = 80
|
|
86
|
+
export const DECISION_PUSH_OPTION_LABEL_MAX = 24
|
|
87
|
+
export const DECISION_PUSH_OPTIONS_MAX = 3
|
|
88
|
+
|
|
65
89
|
export type DaemonStatusEventWirePayload = {
|
|
66
90
|
event: DaemonStatusEventName
|
|
67
91
|
timestamp: number
|
|
@@ -78,6 +102,8 @@ export type DaemonStatusEventWirePayload = {
|
|
|
78
102
|
waitKind?: 'external'
|
|
79
103
|
surfaceHidden?: boolean
|
|
80
104
|
muted?: boolean
|
|
105
|
+
/** Present only on `agent:decision_needed`. */
|
|
106
|
+
decision?: DecisionPushWire
|
|
81
107
|
}
|
|
82
108
|
|
|
83
109
|
// ─── Status report (mirror of daemon-core `CloudStatusReportPayload`) ──────
|
|
@@ -263,8 +289,8 @@ export const DAEMON_P2P_SIGNAL_TYPES = ['p2p_offer', 'p2p_ice'] as const
|
|
|
263
289
|
export type DaemonP2PSignalType = typeof DAEMON_P2P_SIGNAL_TYPES[number]
|
|
264
290
|
export const isDaemonP2PSignalType = makeTypeGuard(DAEMON_P2P_SIGNAL_TYPES)
|
|
265
291
|
|
|
266
|
-
export type P2POfferWirePayload = { sdp: string; type: string; peerId: string; attemptId?: number; attemptEpoch?: string }
|
|
267
|
-
export type P2PIceWirePayload = { candidate: string; mid: string; peerId: string; attemptId?: number; attemptEpoch?: string }
|
|
292
|
+
export type P2POfferWirePayload = { sdp: string; type: string; peerId: string; attemptId?: number; attemptEpoch?: string; probe?: boolean }
|
|
293
|
+
export type P2PIceWirePayload = { candidate: string; mid: string; peerId: string; attemptId?: number; attemptEpoch?: string; probe?: boolean }
|
|
268
294
|
|
|
269
295
|
/**
|
|
270
296
|
* A dashboard-originated signal as relayed to the daemon. `peerId` is stamped
|
|
@@ -278,6 +304,10 @@ export type RelayedP2PSignalWirePayload = {
|
|
|
278
304
|
daemonId?: string
|
|
279
305
|
attemptId?: number
|
|
280
306
|
attemptEpoch?: string
|
|
307
|
+
/** p2p_ready only: the dashboard's stale-connect backoff index (see DashboardP2PReadyWirePayload). */
|
|
308
|
+
backoffStep?: number
|
|
309
|
+
/** Make-before-break relay->direct re-probe of an existing peer (see DashboardP2PReadyWirePayload). */
|
|
310
|
+
probe?: boolean
|
|
281
311
|
sharePermission?: string
|
|
282
312
|
shareTargetSessionId?: string
|
|
283
313
|
sdp?: string
|
|
@@ -154,6 +154,7 @@ export type DashboardP2POfferWirePayload = {
|
|
|
154
154
|
peerId?: string
|
|
155
155
|
attemptId?: number
|
|
156
156
|
attemptEpoch?: string
|
|
157
|
+
probe?: boolean
|
|
157
158
|
sdp: string
|
|
158
159
|
type: string
|
|
159
160
|
}
|
|
@@ -163,6 +164,7 @@ export type DashboardP2PIceWirePayload = {
|
|
|
163
164
|
peerId?: string
|
|
164
165
|
attemptId?: number
|
|
165
166
|
attemptEpoch?: string
|
|
167
|
+
probe?: boolean
|
|
166
168
|
candidate: string
|
|
167
169
|
mid?: string
|
|
168
170
|
sdpMid?: string | null
|
|
@@ -238,13 +240,16 @@ export const DASHBOARD_P2P_SIGNAL_TYPES = ['p2p_ready', 'p2p_answer', 'p2p_ice']
|
|
|
238
240
|
export type DashboardP2PSignalType = typeof DASHBOARD_P2P_SIGNAL_TYPES[number]
|
|
239
241
|
export const isDashboardP2PSignalType = makeTypeGuard(DASHBOARD_P2P_SIGNAL_TYPES)
|
|
240
242
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
+
/** `backoffStep`: the dashboard's stale-connect backoff index (0 = 15s, 1 = 30s, 2 = 60s), so the daemon holds the connecting slot for the same window. */
|
|
244
|
+
/** `probe`: identifier-only flag — this signal belongs to a make-before-break relay->direct re-probe of the SAME logical peer (never a new peer for the connection cap). */
|
|
245
|
+
export type DashboardP2PReadyWirePayload = { daemonId: string; peerId: string; attemptId?: number; attemptEpoch?: string; backoffStep?: number; probe?: boolean }
|
|
246
|
+
export type DashboardP2PAnswerWirePayload = { daemonId: string; peerId: string; sdp: string; type: string; attemptId?: number; attemptEpoch?: string; probe?: boolean }
|
|
243
247
|
export type DashboardP2PLocalIceWirePayload = {
|
|
244
248
|
daemonId: string
|
|
245
249
|
peerId: string
|
|
246
250
|
attemptId?: number
|
|
247
251
|
attemptEpoch?: string
|
|
252
|
+
probe?: boolean
|
|
248
253
|
candidate: string
|
|
249
254
|
sdpMid: string | null
|
|
250
255
|
sdpMLineIndex: number | null
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay -> direct re-probe scheduler (pure, dependency-free).
|
|
3
|
+
*
|
|
4
|
+
* A P2P connection that settled on a TURN relay never looks for a direct path
|
|
5
|
+
* again. The owning transport (dashboard `P2PConnection`, daemon peer manager)
|
|
6
|
+
* uses this scheduler to periodically attempt a make-before-break probe: a
|
|
7
|
+
* SECOND peer connection (STUN-only) is built next to the working relay one and
|
|
8
|
+
* only swapped in once it is verified direct. The scheduler owns nothing but
|
|
9
|
+
* timing: backoff 60s -> 5min (doubling), jitter, and the stop conditions. The
|
|
10
|
+
* transport supplies `shouldProbe` (open + idle + visible) and `probe`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type RelayReprobeOutcome = 'direct' | 'relay' | 'failed' | 'skipped'
|
|
14
|
+
|
|
15
|
+
export interface RelayReprobeTiming {
|
|
16
|
+
/** First delay and the unit of growth (default 60s). */
|
|
17
|
+
baseDelayMs?: number
|
|
18
|
+
/** Backoff ceiling before jitter (default 5min). */
|
|
19
|
+
maxDelayMs?: number
|
|
20
|
+
/** +/- fraction applied to every delay (default 0.2). */
|
|
21
|
+
jitterRatio?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const RELAY_REPROBE_BASE_DELAY_MS = 60_000
|
|
25
|
+
export const RELAY_REPROBE_MAX_DELAY_MS = 5 * 60_000
|
|
26
|
+
export const RELAY_REPROBE_JITTER_RATIO = 0.2
|
|
27
|
+
|
|
28
|
+
/** Delay before the next attempt after `failures` consecutive non-direct outcomes. */
|
|
29
|
+
export function computeRelayReprobeDelay(
|
|
30
|
+
failures: number,
|
|
31
|
+
random: () => number,
|
|
32
|
+
timing: RelayReprobeTiming = {},
|
|
33
|
+
): number {
|
|
34
|
+
const base = timing.baseDelayMs ?? RELAY_REPROBE_BASE_DELAY_MS
|
|
35
|
+
const max = timing.maxDelayMs ?? RELAY_REPROBE_MAX_DELAY_MS
|
|
36
|
+
const jitter = timing.jitterRatio ?? RELAY_REPROBE_JITTER_RATIO
|
|
37
|
+
const grown = Math.min(max, base * 2 ** Math.max(0, failures))
|
|
38
|
+
const factor = 1 + jitter * (Math.min(1, Math.max(0, random())) * 2 - 1)
|
|
39
|
+
return Math.round(grown * factor)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RelayReprobeSchedulerOptions extends RelayReprobeTiming {
|
|
43
|
+
random: () => number
|
|
44
|
+
setTimer: (callback: () => void, ms: number) => unknown
|
|
45
|
+
clearTimer: (handle: unknown) => void
|
|
46
|
+
/** Connected over relay, idle, tab visible, auth epoch unchanged... Re-checked on every tick. */
|
|
47
|
+
shouldProbe: () => boolean
|
|
48
|
+
/** One make-before-break attempt. Must never throw into the scheduler; a rejection counts as 'failed'. */
|
|
49
|
+
probe: () => Promise<RelayReprobeOutcome>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class RelayReprobeScheduler {
|
|
53
|
+
private handle: unknown = null
|
|
54
|
+
private running = false
|
|
55
|
+
private inFlight = false
|
|
56
|
+
private failureCount = 0
|
|
57
|
+
private generation = 0
|
|
58
|
+
|
|
59
|
+
constructor(private readonly options: RelayReprobeSchedulerOptions) {}
|
|
60
|
+
|
|
61
|
+
get isRunning(): boolean { return this.running }
|
|
62
|
+
get failures(): number { return this.failureCount }
|
|
63
|
+
get probing(): boolean { return this.inFlight }
|
|
64
|
+
|
|
65
|
+
/** Begin probing (idempotent). Call when a connection is selected as relay. */
|
|
66
|
+
start(): void {
|
|
67
|
+
if (this.running) return
|
|
68
|
+
this.running = true
|
|
69
|
+
this.failureCount = 0
|
|
70
|
+
this.generation++
|
|
71
|
+
this.schedule()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Stop for good (direct path found, connection closed, auth epoch changed). */
|
|
75
|
+
stop(): void {
|
|
76
|
+
this.running = false
|
|
77
|
+
this.generation++
|
|
78
|
+
if (this.handle !== null) {
|
|
79
|
+
this.options.clearTimer(this.handle)
|
|
80
|
+
this.handle = null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private schedule(): void {
|
|
85
|
+
if (!this.running) return
|
|
86
|
+
const generation = this.generation
|
|
87
|
+
const delay = computeRelayReprobeDelay(this.failureCount, this.options.random, this.options)
|
|
88
|
+
this.handle = this.options.setTimer(() => {
|
|
89
|
+
this.handle = null
|
|
90
|
+
void this.tick(generation)
|
|
91
|
+
}, delay)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async tick(generation: number): Promise<void> {
|
|
95
|
+
if (!this.running || generation !== this.generation) return
|
|
96
|
+
if (this.inFlight || !this.options.shouldProbe()) {
|
|
97
|
+
this.schedule()
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
this.inFlight = true
|
|
101
|
+
let outcome: RelayReprobeOutcome
|
|
102
|
+
try {
|
|
103
|
+
outcome = await this.options.probe()
|
|
104
|
+
} catch {
|
|
105
|
+
outcome = 'failed'
|
|
106
|
+
}
|
|
107
|
+
this.inFlight = false
|
|
108
|
+
// stop()/restart while the probe was in flight: the verdict is stale.
|
|
109
|
+
if (!this.running || generation !== this.generation) return
|
|
110
|
+
if (outcome === 'direct') {
|
|
111
|
+
this.stop()
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
if (outcome !== 'skipped') this.failureCount++
|
|
115
|
+
this.schedule()
|
|
116
|
+
}
|
|
117
|
+
}
|