@raidiant/notifai 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands.d.ts +6 -0
- package/dist/commands.js +509 -94
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +4 -0
- package/dist/hooks.js +68 -28
- package/dist/hooks.js.map +1 -1
- package/dist/main.js +3 -3
- package/dist/main.js.map +1 -1
- package/dist/send.js +1 -0
- package/dist/send.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +442 -18
- package/src/commands.ts +593 -82
- package/src/hooks.test.ts +126 -1
- package/src/hooks.ts +77 -27
- package/src/main.ts +3 -3
- package/src/send.ts +3 -0
package/src/hooks.test.ts
CHANGED
|
@@ -523,6 +523,37 @@ describe('superseding a live question', () => {
|
|
|
523
523
|
expect(readSessionState('sup2', h.env).retiring).toEqual([])
|
|
524
524
|
})
|
|
525
525
|
|
|
526
|
+
it('keeps the original delivery targets when routing changes before retirement', async () => {
|
|
527
|
+
const h = harness([])
|
|
528
|
+
writeSessionState('sup-targets', h.env, { last_prompt_at: AWAY })
|
|
529
|
+
registerQuestion('sup-targets', h.env, { question: 'Ship it?' })
|
|
530
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup-targets' }))
|
|
531
|
+
|
|
532
|
+
const live = readSessionState('sup-targets', h.env).pending
|
|
533
|
+
expect(live).toMatchObject({
|
|
534
|
+
request_id: 'req_hook_1',
|
|
535
|
+
device_ids: ['dev_iphone', 'dev_mac'],
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
// The user changes their default routing before the agent supersedes the
|
|
539
|
+
// question. Retirement belongs to the Deliveries that actually carried the
|
|
540
|
+
// first question, not to whichever Device Installation is selected now.
|
|
541
|
+
const configDir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
542
|
+
mkdirSync(configDir, { recursive: true })
|
|
543
|
+
writeFileSync(path.join(configDir, 'config.toml'), 'devices = ["dev_iphone"]\n')
|
|
544
|
+
registerQuestion('sup-targets', h.env, { question: 'Deploy it?' })
|
|
545
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup-targets' }))
|
|
546
|
+
|
|
547
|
+
const retirement = h.recorder.submitted.find(
|
|
548
|
+
(submission) => submission.draft.event === 'question_retired',
|
|
549
|
+
)
|
|
550
|
+
expect(retirement?.draft.lifecycle?.retires_request_id).toBe('req_hook_1')
|
|
551
|
+
expect(retirement?.draft.targets).toEqual({
|
|
552
|
+
mode: 'selected',
|
|
553
|
+
device_ids: ['dev_iphone', 'dev_mac'],
|
|
554
|
+
})
|
|
555
|
+
})
|
|
556
|
+
|
|
526
557
|
it('sweeps a queued retirement even on a turn continuing from an answer', async () => {
|
|
527
558
|
// stop_hook_active short-circuits the escalation path, and a superseding
|
|
528
559
|
// turn is very often exactly this turn.
|
|
@@ -641,7 +672,13 @@ describe('a question that outlives its session', () => {
|
|
|
641
672
|
const h = harness([])
|
|
642
673
|
orphanRetirements(
|
|
643
674
|
h.env,
|
|
644
|
-
[{
|
|
675
|
+
[{
|
|
676
|
+
request_id: 'req_old',
|
|
677
|
+
collapse_key: 'ck_old',
|
|
678
|
+
device_ids: ['dev_iphone'],
|
|
679
|
+
question: 'Old?',
|
|
680
|
+
state: 'expired',
|
|
681
|
+
}],
|
|
645
682
|
undefined,
|
|
646
683
|
NOW - 25 * 3600 * 1000,
|
|
647
684
|
)
|
|
@@ -735,6 +772,23 @@ describe('ask registration', () => {
|
|
|
735
772
|
expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
736
773
|
expect(readSessionState('solo-session', h.env).pending?.question).toBe('Ship it?')
|
|
737
774
|
})
|
|
775
|
+
|
|
776
|
+
it('fails explicitly instead of rerouting incomplete live state', () => {
|
|
777
|
+
const h = harness()
|
|
778
|
+
writeSessionState('incomplete', h.env, {
|
|
779
|
+
pending: {
|
|
780
|
+
question: 'Original?',
|
|
781
|
+
request_id: 'req_original',
|
|
782
|
+
collapse_key: 'collapse-original',
|
|
783
|
+
},
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
expect(askCommand(h.deps, 'Replacement?', { session: 'incomplete' })).toBe(EXIT.failed)
|
|
787
|
+
expect(h.io.errLines.join('\n')).toContain(
|
|
788
|
+
'refusing to retire it without request, collapse, and device identifiers',
|
|
789
|
+
)
|
|
790
|
+
expect(readSessionState('incomplete', h.env).pending?.question).toBe('Original?')
|
|
791
|
+
})
|
|
738
792
|
})
|
|
739
793
|
|
|
740
794
|
describe('user-prompt-submit hook', () => {
|
|
@@ -744,6 +798,59 @@ describe('user-prompt-submit hook', () => {
|
|
|
744
798
|
expect(readSessionState('s10', h.env).last_prompt_at).toBe(NOW)
|
|
745
799
|
})
|
|
746
800
|
|
|
801
|
+
it('preserves a delivered question through ask replacement and prompt transition', async () => {
|
|
802
|
+
const h = harness([], 900)
|
|
803
|
+
await hookRunCommand(
|
|
804
|
+
h.deps,
|
|
805
|
+
'user-prompt-submit',
|
|
806
|
+
stdin({ session_id: 'transition', cwd: h.deps.cwd }),
|
|
807
|
+
)
|
|
808
|
+
expect(askCommand(h.deps, 'Ship it?', { choice: ['Yes', 'No'] })).toBe(EXIT.ok)
|
|
809
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
|
|
810
|
+
|
|
811
|
+
const live = readSessionState('transition', h.env).pending
|
|
812
|
+
expect(live).toMatchObject({
|
|
813
|
+
question: 'Ship it?',
|
|
814
|
+
request_id: 'req_hook_1',
|
|
815
|
+
device_ids: ['dev_iphone', 'dev_mac'],
|
|
816
|
+
})
|
|
817
|
+
expect(live?.collapse_key).toMatch(/^notifai-hook-/)
|
|
818
|
+
|
|
819
|
+
// A second plain `ask` resolves through the project pointer and supersedes
|
|
820
|
+
// the live question. The next prompt then resets presence before another
|
|
821
|
+
// Stop can run — the transition that used to erase the retirement debt.
|
|
822
|
+
expect(askCommand(h.deps, 'Deploy it?', { choice: ['Staging', 'Production'] })).toBe(EXIT.ok)
|
|
823
|
+
expect(readSessionState('transition', h.env).retiring).toEqual([
|
|
824
|
+
{
|
|
825
|
+
request_id: live?.request_id,
|
|
826
|
+
collapse_key: live?.collapse_key,
|
|
827
|
+
device_ids: ['dev_iphone', 'dev_mac'],
|
|
828
|
+
question: 'Ship it?',
|
|
829
|
+
state: 'superseded',
|
|
830
|
+
},
|
|
831
|
+
])
|
|
832
|
+
|
|
833
|
+
await hookRunCommand(
|
|
834
|
+
h.deps,
|
|
835
|
+
'user-prompt-submit',
|
|
836
|
+
stdin({ session_id: 'transition', cwd: h.deps.cwd }),
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
const retirement = h.recorder.submitted.find(
|
|
840
|
+
(submission) => submission.draft.event === 'question_retired',
|
|
841
|
+
)?.draft
|
|
842
|
+
expect(retirement?.delivery.collapse_key).toBe(live?.collapse_key)
|
|
843
|
+
expect(retirement?.lifecycle).toEqual({
|
|
844
|
+
tier: 'done',
|
|
845
|
+
state: 'superseded',
|
|
846
|
+
retires_request_id: live?.request_id,
|
|
847
|
+
})
|
|
848
|
+
expect(retirement?.targets).toEqual({
|
|
849
|
+
mode: 'selected',
|
|
850
|
+
device_ids: ['dev_iphone', 'dev_mac'],
|
|
851
|
+
})
|
|
852
|
+
})
|
|
853
|
+
|
|
747
854
|
it('retires a question a real timed-out Stop left live on the devices', async () => {
|
|
748
855
|
// Drives the actual flow rather than hand-writing state: the previous
|
|
749
856
|
// version of this test fabricated a shape production never wrote, so it
|
|
@@ -832,6 +939,24 @@ describe('session-end hook', () => {
|
|
|
832
939
|
expect(readSessionState('s12', h.env)).toEqual({})
|
|
833
940
|
expect(h.recorder.closed).toEqual([])
|
|
834
941
|
})
|
|
942
|
+
|
|
943
|
+
it('preserves incomplete live state and reports why it cannot retire it', async () => {
|
|
944
|
+
const h = harness()
|
|
945
|
+
writeSessionState('s-incomplete', h.env, {
|
|
946
|
+
pending: {
|
|
947
|
+
question: 'Still live?',
|
|
948
|
+
request_id: 'req_incomplete',
|
|
949
|
+
collapse_key: 'collapse-incomplete',
|
|
950
|
+
},
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 's-incomplete' }))
|
|
954
|
+
|
|
955
|
+
expect(readSessionState('s-incomplete', h.env).pending?.request_id).toBe('req_incomplete')
|
|
956
|
+
expect(h.io.errLines.join('\n')).toContain(
|
|
957
|
+
'refusing to retire it without request, collapse, and device identifiers',
|
|
958
|
+
)
|
|
959
|
+
})
|
|
835
960
|
})
|
|
836
961
|
|
|
837
962
|
describe('malformed input', () => {
|
package/src/hooks.ts
CHANGED
|
@@ -67,6 +67,8 @@ export interface SessionState {
|
|
|
67
67
|
export interface RetiringQuestion {
|
|
68
68
|
request_id: string
|
|
69
69
|
collapse_key: string
|
|
70
|
+
/** The Device Installations that actually received the question. */
|
|
71
|
+
device_ids: string[]
|
|
70
72
|
/** Shown if the companion has no history entry to correlate against. */
|
|
71
73
|
question: string
|
|
72
74
|
state: LifecycleEndState
|
|
@@ -118,6 +120,8 @@ export interface PendingQuestion {
|
|
|
118
120
|
/** Set once the question has actually been pushed, so it can be retired. */
|
|
119
121
|
request_id?: string
|
|
120
122
|
collapse_key?: string
|
|
123
|
+
/** Exact fanout of the live question; routing config may change afterwards. */
|
|
124
|
+
device_ids?: string[]
|
|
121
125
|
}
|
|
122
126
|
|
|
123
127
|
/** How often the grace window rechecks whether the user has come back. */
|
|
@@ -649,24 +653,56 @@ export function parkForRetirement(
|
|
|
649
653
|
pending: PendingQuestion,
|
|
650
654
|
state: LifecycleEndState,
|
|
651
655
|
): void {
|
|
652
|
-
|
|
656
|
+
const retirement = retiringQuestion(pending, state)
|
|
657
|
+
if (retirement === null) return
|
|
653
658
|
const current = readSessionState(sessionId, env)
|
|
654
659
|
const already = current.retiring ?? []
|
|
655
|
-
if (already.some((entry) => entry.request_id ===
|
|
660
|
+
if (already.some((entry) => entry.request_id === retirement.request_id)) return
|
|
656
661
|
writeSessionState(sessionId, env, {
|
|
657
662
|
...current,
|
|
658
|
-
retiring: [
|
|
659
|
-
...already,
|
|
660
|
-
{
|
|
661
|
-
request_id: pending.request_id,
|
|
662
|
-
collapse_key: pending.collapse_key,
|
|
663
|
-
question: pending.question,
|
|
664
|
-
state,
|
|
665
|
-
},
|
|
666
|
-
],
|
|
663
|
+
retiring: [...already, retirement],
|
|
667
664
|
})
|
|
668
665
|
}
|
|
669
666
|
|
|
667
|
+
/**
|
|
668
|
+
* Convert live question state into a complete retirement instruction.
|
|
669
|
+
*
|
|
670
|
+
* A request/collapse pair without the original Delivery targets is not safe to
|
|
671
|
+
* send: re-resolving today's routing can miss a device that still shows the
|
|
672
|
+
* question or retire a device that never received it. Fail explicitly and keep
|
|
673
|
+
* the pending record instead of silently broadening or narrowing the fanout.
|
|
674
|
+
*/
|
|
675
|
+
function retiringQuestion(
|
|
676
|
+
pending: PendingQuestion,
|
|
677
|
+
state: LifecycleEndState,
|
|
678
|
+
): RetiringQuestion | null {
|
|
679
|
+
const hasRequest = pending.request_id !== undefined
|
|
680
|
+
const hasCollapse = pending.collapse_key !== undefined
|
|
681
|
+
const hasDevices = pending.device_ids !== undefined && pending.device_ids.length > 0
|
|
682
|
+
if (!hasRequest && !hasCollapse && !hasDevices) return null
|
|
683
|
+
if (!hasRequest || !hasCollapse || !hasDevices) {
|
|
684
|
+
throw new Error(
|
|
685
|
+
'live question state is incomplete; refusing to retire it without request, collapse, and device identifiers',
|
|
686
|
+
)
|
|
687
|
+
}
|
|
688
|
+
return {
|
|
689
|
+
request_id: pending.request_id!,
|
|
690
|
+
collapse_key: pending.collapse_key!,
|
|
691
|
+
device_ids: [...pending.device_ids!],
|
|
692
|
+
question: pending.question,
|
|
693
|
+
state,
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function retirementDeviceIds(entry: RetiringQuestion): string[] {
|
|
698
|
+
if (!Array.isArray(entry.device_ids) || entry.device_ids.length === 0) {
|
|
699
|
+
throw new Error(
|
|
700
|
+
`retirement for ${entry.request_id} is missing its device identifiers; refusing to re-resolve routing`,
|
|
701
|
+
)
|
|
702
|
+
}
|
|
703
|
+
return entry.device_ids
|
|
704
|
+
}
|
|
705
|
+
|
|
670
706
|
/**
|
|
671
707
|
* Send every parked retirement, and forget the ones that landed. A failure
|
|
672
708
|
* leaves the entry in place for the next hook rather than losing it, which is
|
|
@@ -692,7 +728,7 @@ export async function drainRetirements(
|
|
|
692
728
|
entry.question,
|
|
693
729
|
entry.state,
|
|
694
730
|
entry.request_id,
|
|
695
|
-
|
|
731
|
+
retirementDeviceIds(entry),
|
|
696
732
|
session,
|
|
697
733
|
)
|
|
698
734
|
if (sent) retired.push(entry.request_id)
|
|
@@ -777,7 +813,7 @@ export async function drainOrphanRetirements(
|
|
|
777
813
|
entry.question,
|
|
778
814
|
entry.state,
|
|
779
815
|
entry.request_id,
|
|
780
|
-
|
|
816
|
+
retirementDeviceIds(entry),
|
|
781
817
|
entry.session,
|
|
782
818
|
)
|
|
783
819
|
if (sent) done.push(entry.request_id)
|
|
@@ -811,18 +847,19 @@ export async function handleUserPromptSubmit(
|
|
|
811
847
|
|
|
812
848
|
const state = readSessionState(sessionId, ctx.env)
|
|
813
849
|
const pending = state.pending
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
//
|
|
850
|
+
// Park before dropping `pending`. If the process dies between these writes,
|
|
851
|
+
// the next hook sees both copies and dedupes them; the old order could die in
|
|
852
|
+
// the gap after erasing the only request/collapse/device identifiers.
|
|
853
|
+
if (pending !== undefined) parkForRetirement(sessionId, ctx.env, pending, 'answered_elsewhere')
|
|
854
|
+
const parked = readSessionState(sessionId, ctx.env)
|
|
817
855
|
writeSessionState(sessionId, ctx.env, {
|
|
818
856
|
last_prompt_at: ctx.now(),
|
|
819
|
-
...(
|
|
857
|
+
...(parked.retiring !== undefined ? { retiring: parked.retiring } : {}),
|
|
820
858
|
})
|
|
821
859
|
// The bridge that lets a plain `notifai ask` find its own session: an agent
|
|
822
860
|
// shell command gets no hook payload and no harness exports the id.
|
|
823
861
|
if (envelope.cwd !== undefined) writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now())
|
|
824
862
|
|
|
825
|
-
if (pending !== undefined) parkForRetirement(sessionId, ctx.env, pending, 'answered_elsewhere')
|
|
826
863
|
const retired = await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
|
|
827
864
|
const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now())
|
|
828
865
|
const swept = [...retired, ...orphaned]
|
|
@@ -938,7 +975,12 @@ async function escalate(
|
|
|
938
975
|
// snapshot in `state` predates it.
|
|
939
976
|
writeSessionState(sessionId, ctx.env, {
|
|
940
977
|
...readSessionState(sessionId, ctx.env),
|
|
941
|
-
pending: {
|
|
978
|
+
pending: {
|
|
979
|
+
...pending,
|
|
980
|
+
request_id: live.requestId,
|
|
981
|
+
collapse_key: live.collapseKey,
|
|
982
|
+
device_ids: live.devices,
|
|
983
|
+
},
|
|
942
984
|
})
|
|
943
985
|
},
|
|
944
986
|
})
|
|
@@ -962,7 +1004,12 @@ async function escalate(
|
|
|
962
1004
|
// set is also what stops the next Stop pushing the same question again.
|
|
963
1005
|
writeSessionState(sessionId, ctx.env, {
|
|
964
1006
|
...carried,
|
|
965
|
-
pending: {
|
|
1007
|
+
pending: {
|
|
1008
|
+
...pending,
|
|
1009
|
+
request_id: asked.requestId,
|
|
1010
|
+
collapse_key: asked.collapseKey,
|
|
1011
|
+
device_ids: asked.devices,
|
|
1012
|
+
},
|
|
966
1013
|
})
|
|
967
1014
|
notes.push(
|
|
968
1015
|
asked.degraded
|
|
@@ -1023,13 +1070,16 @@ export function handleSessionEnd(
|
|
|
1023
1070
|
const state = readSessionState(sessionId, env)
|
|
1024
1071
|
const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
|
|
1025
1072
|
const pending = state.pending
|
|
1026
|
-
if (pending
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1073
|
+
if (pending !== undefined) {
|
|
1074
|
+
try {
|
|
1075
|
+
const orphan = retiringQuestion(pending, 'expired')
|
|
1076
|
+
if (orphan !== null) orphans.push(orphan)
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
notes.push(err instanceof Error ? err.message : String(err))
|
|
1079
|
+
// Preserve the only identifiers instead of turning an explicit corrupt
|
|
1080
|
+
// state into an unretirable question during SessionEnd cleanup.
|
|
1081
|
+
return { notes }
|
|
1082
|
+
}
|
|
1033
1083
|
}
|
|
1034
1084
|
if (orphans.length > 0) {
|
|
1035
1085
|
const label = env['NOTIFAI_SESSION']
|
package/src/main.ts
CHANGED
|
@@ -315,11 +315,11 @@ config
|
|
|
315
315
|
program
|
|
316
316
|
.command('init')
|
|
317
317
|
.description(
|
|
318
|
-
'Set up NotifAI here, idempotently: sign-in, project id, hooks, device readiness. ' +
|
|
318
|
+
'Set up NotifAI here, idempotently: sign-in, project id, hooks, device readiness, live receipt proof. ' +
|
|
319
319
|
'Interactive at a human terminal; never prompts otherwise (agents: pass flags)',
|
|
320
320
|
)
|
|
321
321
|
.option('--project-id <id>', 'project identifier slug (default: derived from the directory name)')
|
|
322
|
-
.option('--skills', '
|
|
322
|
+
.option('--skills', 'install/update the agent skill from its pinned public release')
|
|
323
323
|
.option('--no-skills', 'suppress the optional agent-skill status line')
|
|
324
324
|
.option('--hooks', 'install harness hooks for registered-question routing')
|
|
325
325
|
.option('--no-hooks', 'skip the hooks without being asked')
|
|
@@ -329,7 +329,7 @@ program
|
|
|
329
329
|
|
|
330
330
|
program
|
|
331
331
|
.command('doctor')
|
|
332
|
-
.description('Audit config, credential, server, contract, device, and
|
|
332
|
+
.description('Audit config, credential, server, contract, device, hook, and saved receipt proof (no live send)')
|
|
333
333
|
.option('--json', 'machine-readable output')
|
|
334
334
|
.action(async (opts: { json?: boolean }) => {
|
|
335
335
|
process.exit(await doctorCommand(deps, opts))
|
package/src/send.ts
CHANGED
|
@@ -196,6 +196,9 @@ export function formatReceipt(receipt: SubmissionReceipt): string {
|
|
|
196
196
|
const detail = d.provider_reason ? ` (${d.provider_reason})` : ''
|
|
197
197
|
lines.push(` ${d.device_name}: ${d.state}${detail}`)
|
|
198
198
|
}
|
|
199
|
+
lines.push(
|
|
200
|
+
` device receipt is not assessed here; run notifai status ${receipt.request_id} for Companion Receipt evidence`,
|
|
201
|
+
)
|
|
199
202
|
for (const w of receipt.warnings) lines.push(` warning ${w.path}: ${w.message}`)
|
|
200
203
|
return lines.join('\n')
|
|
201
204
|
}
|