@raidiant/notifai 4.0.0 → 5.0.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 +46 -0
- package/README.md +5 -0
- package/dist/commands.d.ts +10 -1
- package/dist/commands.js +174 -58
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +14 -5
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +6 -1
- package/dist/file-lock.js +55 -9
- package/dist/file-lock.js.map +1 -1
- package/dist/harnesses.d.ts +1 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +72 -5
- package/dist/hooks.js +407 -79
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +91 -14
- package/dist/install-hooks.js +226 -34
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/logging.d.ts +8 -0
- package/dist/logging.js +8 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +44 -17
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +24 -162
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +6 -0
- package/dist/send.js +25 -14
- package/dist/send.js.map +1 -1
- package/dist/ui/help.d.ts +0 -1
- package/dist/ui/help.js +6 -3
- package/dist/ui/help.js.map +1 -1
- package/dist/ui/theme.d.ts +0 -15
- package/dist/ui/theme.js +2 -18
- package/dist/ui/theme.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +185 -51
- package/src/commands.ts +198 -56
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +16 -5
- package/src/config.test.ts +41 -9
- package/src/config.ts +13 -0
- package/src/file-lock.test.ts +41 -0
- package/src/file-lock.ts +59 -9
- package/src/harnesses.test.ts +37 -29
- package/src/harnesses.ts +1 -4
- package/src/hooks.test.ts +358 -21
- package/src/hooks.ts +457 -97
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.ts +1 -1
- package/src/logging-process-worker.test.ts +8 -2
- package/src/logging.test.ts +33 -18
- package/src/logging.ts +11 -0
- package/src/main.ts +57 -23
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/send.ts +32 -16
- package/src/skill.test.ts +199 -146
- package/src/ui/help.ts +8 -3
- package/src/ui/theme.ts +2 -16
package/src/config.test.ts
CHANGED
|
@@ -191,20 +191,32 @@ describe('draft building', () => {
|
|
|
191
191
|
presentation: { title: 'T', body: 'B' },
|
|
192
192
|
targets: { mode: 'all' },
|
|
193
193
|
delivery: { ttl_seconds: 86400, collapse_key: null },
|
|
194
|
+
// An unlabelled draft is ordinary news, and news has a sound.
|
|
195
|
+
platform: { ios: { sound: 'default' }, macos: { sound: 'default' } },
|
|
194
196
|
})
|
|
195
197
|
})
|
|
196
198
|
|
|
197
199
|
it.each([
|
|
198
|
-
{},
|
|
199
|
-
{ kind: '
|
|
200
|
-
{ kind: '
|
|
201
|
-
{ kind: '
|
|
202
|
-
{
|
|
203
|
-
|
|
200
|
+
{ flags: {}, sound: 'default' },
|
|
201
|
+
{ flags: { kind: 'update' }, sound: 'default' },
|
|
202
|
+
{ flags: { kind: 'done' }, sound: 'done' },
|
|
203
|
+
{ flags: { kind: 'failed' }, sound: 'alert' },
|
|
204
|
+
{ flags: { kind: 'blocked' }, sound: 'attention' },
|
|
205
|
+
{ flags: { kind: 'question' }, sound: 'attention' },
|
|
206
|
+
{ flags: { reply: true }, sound: 'attention' },
|
|
207
|
+
])('gives $flags the $sound sound when nobody chose one', ({ flags, sound }) => {
|
|
204
208
|
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
205
209
|
const build = buildDraft(config, { title: 'T', body: 'B', ...flags })
|
|
206
210
|
if (!build.ok) throw new Error(build.error)
|
|
207
|
-
expect(build.draft.platform).
|
|
211
|
+
expect(build.draft.platform?.ios?.sound).toBe(sound)
|
|
212
|
+
expect(build.draft.platform?.ios?.interruption_level).toBeUndefined()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('lets an explicit sound outrank the kind it would have derived', () => {
|
|
216
|
+
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
217
|
+
const explicit = buildDraft(config, { title: 'T', body: 'B', kind: 'failed', sound: 'none' })
|
|
218
|
+
if (!explicit.ok) throw new Error(explicit.error)
|
|
219
|
+
expect(explicit.draft.platform?.ios?.sound).toBeNull()
|
|
208
220
|
})
|
|
209
221
|
|
|
210
222
|
it('orders explicit flags over saved user config', () => {
|
|
@@ -233,12 +245,32 @@ describe('draft building', () => {
|
|
|
233
245
|
})
|
|
234
246
|
})
|
|
235
247
|
|
|
236
|
-
it('keeps a
|
|
248
|
+
it('keeps a question answerable for a day by default', () => {
|
|
249
|
+
// A question asked before someone steps away should still be theirs to
|
|
250
|
+
// answer when they come back; the CLI used to cut the wire contract's own
|
|
251
|
+
// default down to an hour.
|
|
237
252
|
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
238
253
|
const build = buildDraft(config, { title: 'Question', body: 'Deploy?', reply: true })
|
|
239
254
|
if (!build.ok) throw new Error(build.error)
|
|
240
255
|
|
|
241
|
-
expect(build.draft.reply?.expires_in_seconds).toBe(
|
|
256
|
+
expect(build.draft.reply?.expires_in_seconds).toBe(86_400)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('lets the user shorten or lengthen how long an answer is accepted', () => {
|
|
260
|
+
const { env, cwd } = setup({ projectToml: 'reply_window_seconds = 7200\n' })
|
|
261
|
+
const config = loadConfig({ cwd, env })
|
|
262
|
+
const build = buildDraft(config, { title: 'Question', body: 'Deploy?', reply: true })
|
|
263
|
+
if (!build.ok) throw new Error(build.error)
|
|
264
|
+
expect(build.draft.reply?.expires_in_seconds).toBe(7_200)
|
|
265
|
+
|
|
266
|
+
const explicit = buildDraft(config, {
|
|
267
|
+
title: 'Question',
|
|
268
|
+
body: 'Deploy?',
|
|
269
|
+
reply: true,
|
|
270
|
+
replyWindow: 600,
|
|
271
|
+
})
|
|
272
|
+
if (!explicit.ok) throw new Error(explicit.error)
|
|
273
|
+
expect(explicit.draft.reply?.expires_in_seconds).toBe(600)
|
|
242
274
|
})
|
|
243
275
|
|
|
244
276
|
it('orders explicit and configured Project identity over inference', () => {
|
package/src/config.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
|
|
1
2
|
import { createHash } from 'node:crypto'
|
|
2
3
|
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
|
3
4
|
import path from 'node:path'
|
|
@@ -87,6 +88,8 @@ export interface CliConfig {
|
|
|
87
88
|
* usefulness.
|
|
88
89
|
*/
|
|
89
90
|
log_max_bytes: ResolvedValue<number>
|
|
91
|
+
/** How long the server keeps accepting an answer to a question. */
|
|
92
|
+
reply_window_seconds: ResolvedValue<number>
|
|
90
93
|
/** Log files kept in total, the active one included. */
|
|
91
94
|
log_max_files: ResolvedValue<number>
|
|
92
95
|
}
|
|
@@ -102,6 +105,7 @@ export const CONFIG_KEYS = [
|
|
|
102
105
|
'notify_criteria',
|
|
103
106
|
'ask_notifications',
|
|
104
107
|
'ask_grace_seconds',
|
|
108
|
+
'reply_window_seconds',
|
|
105
109
|
'log_level',
|
|
106
110
|
'log_max_bytes',
|
|
107
111
|
'log_max_files',
|
|
@@ -116,6 +120,7 @@ export const NUMERIC_CONFIG_KEYS: readonly ConfigKey[] = [
|
|
|
116
120
|
'wait_seconds',
|
|
117
121
|
'ttl_seconds',
|
|
118
122
|
'ask_grace_seconds',
|
|
123
|
+
'reply_window_seconds',
|
|
119
124
|
'log_max_bytes',
|
|
120
125
|
'log_max_files',
|
|
121
126
|
]
|
|
@@ -149,6 +154,9 @@ const DEFAULTS = {
|
|
|
149
154
|
// Questions reach devices as soon as the turn ends. A terminal-first grace
|
|
150
155
|
// window is an opt-in delay on top of that.
|
|
151
156
|
ask_grace_seconds: 0,
|
|
157
|
+
// A day. The wire contract's own default, and long enough that a question
|
|
158
|
+
// asked before someone steps away is still answerable when they come back.
|
|
159
|
+
reply_window_seconds: 86_400,
|
|
152
160
|
// On by default: the log is the only account of what a headless hook did, and
|
|
153
161
|
// a diagnostic nobody switched on before the thing went wrong is not one.
|
|
154
162
|
log_level: 'info',
|
|
@@ -302,6 +310,10 @@ const NUMERIC_BOUNDS: Partial<Record<ConfigKey, { min: number; max: number }>> =
|
|
|
302
310
|
// longest window a user can choose still leaves an answer window the server
|
|
303
311
|
// will accept — with room to spare for a timer that wakes late.
|
|
304
312
|
ask_grace_seconds: { min: 0, max: 360 },
|
|
313
|
+
// The floor is the shortest window the wire contract accepts; the ceiling is
|
|
314
|
+
// the point past which a question would outlive the retained content it
|
|
315
|
+
// refers to.
|
|
316
|
+
reply_window_seconds: { min: 60, max: REPLY_MAX_WINDOW_SECONDS },
|
|
305
317
|
// The floor keeps rotation from thrashing (a file smaller than a handful of
|
|
306
318
|
// records would rotate on nearly every write); the ceiling is the point past
|
|
307
319
|
// which "local diagnostics" has become "a database on your disk".
|
|
@@ -413,6 +425,7 @@ export function loadConfig(options: {
|
|
|
413
425
|
notify_criteria: resolve('notify_criteria'),
|
|
414
426
|
ask_notifications: resolve('ask_notifications'),
|
|
415
427
|
ask_grace_seconds: resolve('ask_grace_seconds'),
|
|
428
|
+
reply_window_seconds: resolve('reply_window_seconds'),
|
|
416
429
|
log_level: resolve('log_level'),
|
|
417
430
|
log_max_bytes: resolve('log_max_bytes'),
|
|
418
431
|
log_max_files: resolve('log_max_files'),
|
package/src/file-lock.test.ts
CHANGED
|
@@ -206,6 +206,47 @@ describe('cross-process file lock', () => {
|
|
|
206
206
|
}, 30_000)
|
|
207
207
|
})
|
|
208
208
|
|
|
209
|
+
describe('rendezvous contention', () => {
|
|
210
|
+
// The last contender out removes the rendezvous directory, so a contender
|
|
211
|
+
// registering at that instant races the removal. Both tests drive that race
|
|
212
|
+
// from the registering observation, which is the window the racing releaser
|
|
213
|
+
// would otherwise have to hit by luck — on a loaded machine it does.
|
|
214
|
+
function raceAtRegistration(interfere: (lock: string) => void): void {
|
|
215
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-rendezvous-'))
|
|
216
|
+
const lock = path.join(root, 'shared.lock')
|
|
217
|
+
let raced = false
|
|
218
|
+
let entered = 0
|
|
219
|
+
|
|
220
|
+
withFileLock(lock, () => { entered += 1 }, {
|
|
221
|
+
observe(observation) {
|
|
222
|
+
if (observation.phase !== 'registering' || raced) return
|
|
223
|
+
raced = true
|
|
224
|
+
interfere(lock)
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
expect(raced).toBe(true)
|
|
229
|
+
expect(entered).toBe(1)
|
|
230
|
+
// Release still cleans up after itself, whichever rendezvous it ended in.
|
|
231
|
+
expect(existsSync(lock)).toBe(false)
|
|
232
|
+
rmSync(root, { recursive: true, force: true })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
it('registers again when a releasing contender removed the rendezvous', () => {
|
|
236
|
+
raceAtRegistration((lock) => rmSync(lock, { recursive: true }))
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('holds the rendezvous it landed in, not the one it scanned', () => {
|
|
240
|
+
// A third contender recreates the directory before this one publishes, so
|
|
241
|
+
// the entry lands in a directory whose identity nobody has recorded yet.
|
|
242
|
+
// Treating that as tampering used to break the caller outright.
|
|
243
|
+
raceAtRegistration((lock) => {
|
|
244
|
+
rmSync(lock, { recursive: true })
|
|
245
|
+
mkdirSync(lock, { mode: 0o700 })
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
209
250
|
describe('target file transaction lock', () => {
|
|
210
251
|
it('refuses a symlinked target parent before publishing a contender', () => {
|
|
211
252
|
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-target-lock-'))
|
package/src/file-lock.ts
CHANGED
|
@@ -22,6 +22,14 @@ import path from 'node:path'
|
|
|
22
22
|
* process cannot remove a later owner's entry; because process liveness, rather
|
|
23
23
|
* than age, decides recovery, a delayed holder is never mistaken for a dead one.
|
|
24
24
|
*
|
|
25
|
+
* The rendezvous directory lives only as long as it has entries: the last
|
|
26
|
+
* contender out removes it, so an idle machine keeps no lock state. That makes
|
|
27
|
+
* the directory itself a contended resource. A contender registering while
|
|
28
|
+
* another releases can find it gone, half-deleted, or already replaced by a
|
|
29
|
+
* third contender, and none of those is a broken lock — so publication retries
|
|
30
|
+
* within the caller's deadline and pins its rendezvous only once its own entry
|
|
31
|
+
* is in the directory, which is the moment removal becomes impossible.
|
|
32
|
+
*
|
|
25
33
|
* Acquisition is deliberately bounded because both harness hooks and logging
|
|
26
34
|
* must fail open rather than hold an agent turn indefinitely.
|
|
27
35
|
*/
|
|
@@ -32,6 +40,8 @@ const ENTRY_PATTERN = /^(choosing|ticket-(\d+))-(\d+)-([0-9a-f]+)$/
|
|
|
32
40
|
const lockSleep = new Int32Array(new SharedArrayBuffer(4))
|
|
33
41
|
|
|
34
42
|
export type FileLockObservation =
|
|
43
|
+
/** The rendezvous has been scanned and this contender is about to publish. */
|
|
44
|
+
| { phase: 'registering'; entry: string }
|
|
35
45
|
| { phase: 'choosing-published'; entry: string }
|
|
36
46
|
| { phase: 'stale-entry'; entry: string }
|
|
37
47
|
| { phase: 'waiting'; blockers: string[] }
|
|
@@ -125,6 +135,16 @@ function assertOwnedDirectory(directory: string): FileIdentity {
|
|
|
125
135
|
return { dev: stat.dev, ino: stat.ino }
|
|
126
136
|
}
|
|
127
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The rendezvous a contender was using is no longer the directory at that path.
|
|
140
|
+
*
|
|
141
|
+
* Fatal once an entry is published — a contender's own entry makes the
|
|
142
|
+
* directory non-empty, so no cooperating releaser can remove it. Before
|
|
143
|
+
* publication it means only that a releaser removed an empty rendezvous while
|
|
144
|
+
* this contender was registering in it, which is ordinary contention.
|
|
145
|
+
*/
|
|
146
|
+
class RendezvousReplaced extends Error {}
|
|
147
|
+
|
|
128
148
|
function assertSameDirectory(directory: string, expected: FileIdentity): void {
|
|
129
149
|
const current = lstatSync(directory)
|
|
130
150
|
if (
|
|
@@ -133,7 +153,7 @@ function assertSameDirectory(directory: string, expected: FileIdentity): void {
|
|
|
133
153
|
current.dev !== expected.dev ||
|
|
134
154
|
current.ino !== expected.ino
|
|
135
155
|
) {
|
|
136
|
-
throw new
|
|
156
|
+
throw new RendezvousReplaced(`${directory} changed while the file lock was active.`)
|
|
137
157
|
}
|
|
138
158
|
}
|
|
139
159
|
|
|
@@ -207,6 +227,24 @@ function publishEmpty(file: string): EntryIdentity {
|
|
|
207
227
|
}
|
|
208
228
|
}
|
|
209
229
|
|
|
230
|
+
/**
|
|
231
|
+
* True when a publication attempt lost its rendezvous rather than found a
|
|
232
|
+
* broken one.
|
|
233
|
+
*
|
|
234
|
+
* A releaser removes an empty rendezvous directory the moment the last entry
|
|
235
|
+
* goes, so a contender registering at that instant sees the removal from
|
|
236
|
+
* whichever side it reached first. All three are the same event: `ENOENT` when
|
|
237
|
+
* the directory is already gone, `EINVAL` when APFS reports a directory that is
|
|
238
|
+
* still being deleted, and a replaced identity when a third contender recreated
|
|
239
|
+
* it first. None of them says anything about the lock's integrity, because a
|
|
240
|
+
* contender with no published entry owns nothing yet.
|
|
241
|
+
*/
|
|
242
|
+
function lostTheRendezvous(err: unknown): boolean {
|
|
243
|
+
if (err instanceof RendezvousReplaced) return true
|
|
244
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
245
|
+
return code === 'ENOENT' || code === 'EINVAL'
|
|
246
|
+
}
|
|
247
|
+
|
|
210
248
|
function publishChoosing(
|
|
211
249
|
directory: string,
|
|
212
250
|
name: string,
|
|
@@ -217,21 +255,33 @@ function publishChoosing(
|
|
|
217
255
|
for (;;) {
|
|
218
256
|
try {
|
|
219
257
|
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
220
|
-
const
|
|
258
|
+
const registrar = assertOwnedDirectory(directory)
|
|
221
259
|
chmodSync(directory, 0o700)
|
|
222
260
|
// Recover before registration so a delayed stale cleanup can only name an
|
|
223
261
|
// old owner's unique path, never this contender's future one.
|
|
224
|
-
liveEntries(directory,
|
|
262
|
+
liveEntries(directory, registrar, observe)
|
|
263
|
+
observe?.({ phase: 'registering', entry: name })
|
|
225
264
|
const fileIdentity = publishEmpty(file)
|
|
226
|
-
|
|
265
|
+
// Pin the rendezvous that holds the entry, not the one scanned before it
|
|
266
|
+
// existed. `open` resolves the path atomically, so the entry lands in
|
|
267
|
+
// whichever directory the path named at that instant — and from then on
|
|
268
|
+
// that directory is non-empty, so no cooperating releaser can remove it.
|
|
269
|
+
// Reading the identity here therefore names the contender's real
|
|
270
|
+
// rendezvous even when the scanned one was removed and recreated
|
|
271
|
+
// underneath it.
|
|
272
|
+
const directoryIdentity = assertOwnedDirectory(directory)
|
|
273
|
+
if (!sameEntryIdentity(file, fileIdentity)) {
|
|
274
|
+
throw new RendezvousReplaced(`${directory} no longer holds ${name}.`)
|
|
275
|
+
}
|
|
227
276
|
return { file, fileIdentity, directoryIdentity }
|
|
228
277
|
} catch (err) {
|
|
229
|
-
if ((err
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
// releaser can remove the non-empty directory underneath us.
|
|
278
|
+
if (!lostTheRendezvous(err)) throw err
|
|
279
|
+
// Recreate it and register again; the deadline, not the number of losses,
|
|
280
|
+
// decides when contention becomes a failure.
|
|
233
281
|
if (Date.now() >= deadline) {
|
|
234
|
-
throw new Error(
|
|
282
|
+
throw new Error(
|
|
283
|
+
`timed out publishing file lock contender in ${directory}: ${(err as Error).message}`,
|
|
284
|
+
)
|
|
235
285
|
}
|
|
236
286
|
Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS)
|
|
237
287
|
}
|
package/src/harnesses.test.ts
CHANGED
|
@@ -1,34 +1,42 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { HARNESS_CAPABILITIES, HARNESSES } from './harnesses.js'
|
|
2
|
+
import { HARNESS_CAPABILITIES, HARNESS_LABELS, HARNESSES } from './harnesses.js'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* This file used to hold a verbatim copy of the table below it, which meant
|
|
6
|
+
* changing a harness contract required changing the same words twice and the
|
|
7
|
+
* test could never disagree with the code — only with whoever forgot the second
|
|
8
|
+
* edit. What is worth asserting is the shape the rest of the CLI assumes.
|
|
9
|
+
*/
|
|
10
|
+
describe('harness contract', () => {
|
|
11
|
+
it('describes every shipped harness, and only those', () => {
|
|
6
12
|
expect(Object.keys(HARNESS_CAPABILITIES).sort()).toEqual([...HARNESSES].sort())
|
|
7
|
-
expect(
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
13
|
+
expect(Object.keys(HARNESS_LABELS).sort()).toEqual([...HARNESSES].sort())
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('gives a harness that cannot continue a turn no route to pretend with', () => {
|
|
17
|
+
// `ask` fails closed on these, and the reason it can is that the two facts
|
|
18
|
+
// agree. A harness claiming a delivery route while declaring it cannot
|
|
19
|
+
// continue would accept an answer into a void.
|
|
20
|
+
for (const [harness, capability] of Object.entries(HARNESS_CAPABILITIES)) {
|
|
21
|
+
if (capability.stopContinuation !== 'unsupported') continue
|
|
22
|
+
expect(capability.deliveryRoutes, harness).toEqual(['unsupported'])
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('gives a harness that can continue a turn at least one real route', () => {
|
|
27
|
+
for (const [harness, capability] of Object.entries(HARNESS_CAPABILITIES)) {
|
|
28
|
+
if (capability.stopContinuation === 'unsupported') continue
|
|
29
|
+
expect(capability.deliveryRoutes.length, harness).toBeGreaterThan(0)
|
|
30
|
+
expect(capability.deliveryRoutes, harness).not.toContain('unsupported')
|
|
31
|
+
// The journal is the floor under every supported route: without it an
|
|
32
|
+
// answer that misses its turn has nowhere to wait.
|
|
33
|
+
expect(capability.deliveryRoutes, harness).toContain('hold-for-next-turn')
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('explains each harness in prose an operator can act on', () => {
|
|
38
|
+
for (const [harness, capability] of Object.entries(HARNESS_CAPABILITIES)) {
|
|
39
|
+
expect(capability.deliveryContract.length, harness).toBeGreaterThan(40)
|
|
40
|
+
}
|
|
33
41
|
})
|
|
34
42
|
})
|
package/src/harnesses.ts
CHANGED
|
@@ -18,10 +18,7 @@ export type DeliveryRoute =
|
|
|
18
18
|
| 'hold-for-next-turn'
|
|
19
19
|
| 'unsupported'
|
|
20
20
|
|
|
21
|
-
export type StopContinuation =
|
|
22
|
-
| 'decision-block'
|
|
23
|
-
| 'followup-message'
|
|
24
|
-
| 'unsupported'
|
|
21
|
+
export type StopContinuation = 'decision-block' | 'unsupported'
|
|
25
22
|
|
|
26
23
|
export interface HarnessCapability {
|
|
27
24
|
/** How an answer is admitted to another turn in the already-open session. */
|