@pugi/cli 0.1.0-beta.31 → 0.1.0-beta.36
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/smoke.js +133 -0
- package/dist/core/auth/ensure-authenticated.js +129 -0
- package/dist/core/bash-classifier.js +108 -1
- package/dist/core/codegraph/decision-store.js +248 -0
- package/dist/core/codegraph/detect-repo.js +459 -0
- package/dist/core/codegraph/install.js +134 -0
- package/dist/core/codegraph/offer-hook.js +220 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +50 -4
- package/dist/core/mcp/orchestrator-tools.js +595 -0
- package/dist/core/onboarding/ensure-initialized.js +133 -0
- package/dist/core/repl/session.js +370 -9
- package/dist/core/repl/slash-commands.js +68 -5
- package/dist/core/smoke/headless-driver.js +174 -0
- package/dist/core/smoke/orchestrator.js +194 -0
- package/dist/core/smoke/runner.js +238 -0
- package/dist/core/smoke/scenario-parser.js +316 -0
- package/dist/runtime/cli.js +453 -11
- package/dist/runtime/commands/cancel.js +231 -0
- package/dist/runtime/commands/codegraph-status.js +227 -0
- package/dist/runtime/commands/mcp.js +66 -11
- package/dist/runtime/commands/permissions.js +23 -0
- package/dist/runtime/commands/redo-blob-store.js +92 -0
- package/dist/runtime/commands/redo.js +361 -0
- package/dist/runtime/commands/status.js +11 -3
- package/dist/runtime/commands/undo.js +32 -0
- package/dist/runtime/headless-repl.js +195 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tui/permissions-picker.js +78 -0
- package/dist/tui/render.js +35 -0
- package/dist/tui/status-bar.js +1 -1
- package/dist/tui/tool-stream-pane.js +45 -3
- package/package.json +7 -4
- package/test/scenarios/codegen-create-file.scenario.txt +13 -0
- package/test/scenarios/compact-force.scenario.txt +11 -0
- package/test/scenarios/identity.scenario.txt +11 -0
- package/test/scenarios/persona-handoff.scenario.txt +11 -0
- package/test/scenarios/walkback.scenario.txt +12 -0
- package/dist/core/engine/compaction-hook.js +0 -154
- package/dist/core/init/scaffold.js +0 -195
- package/dist/core/memory/dual-write.spec.js +0 -297
- package/dist/core/memory-sync/queue.spec.js +0 -105
- package/dist/core/repl/codebase-survey.js +0 -308
- package/dist/core/repl/init-interview.js +0 -457
- package/dist/core/repl/onboarding-state.js +0 -297
- package/dist/runtime/commands/memory.spec.js +0 -174
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Engine loop integration point for the six-tier compaction engine.
|
|
3
|
-
*
|
|
4
|
-
* `maybeCompactAfterTool` is the single function the engine loop calls
|
|
5
|
-
* after each tool result has been appended to the transcript. It:
|
|
6
|
-
*
|
|
7
|
-
* 1. Estimates current context-window pressure (transcript bytes
|
|
8
|
-
* against the model's budget, plus the static blocks).
|
|
9
|
-
* 2. Calls `selectTier` on the snapshot.
|
|
10
|
-
* 3. Runs the tier. Microcompact / cached_microcompact are sync;
|
|
11
|
-
* reactive_summary / session_memory / full_compaction / reset
|
|
12
|
-
* are async-shaped (the call returns before commit when run
|
|
13
|
-
* against a long transcript) but currently run inline — the
|
|
14
|
-
* engine loop is single-threaded today, so true backgrounding
|
|
15
|
-
* waits for the SSE consumer refactor in α5.7.
|
|
16
|
-
* 4. Runs invariant checks against the result. On any violation,
|
|
17
|
-
* emits `compaction.invariant_violated` and returns the
|
|
18
|
-
* pre-compaction transcript untouched.
|
|
19
|
-
* 5. On success, emits `compaction.completed` with reclaim numbers
|
|
20
|
-
* and returns the new transcript for the caller to adopt.
|
|
21
|
-
* 6. On no-op, emits `compaction.skipped` and returns the original.
|
|
22
|
-
*
|
|
23
|
-
* Why a separate file (not inlined into `native-pugi.ts`):
|
|
24
|
-
*
|
|
25
|
-
* Sprint α5.3 (feat/pugi-cli-hooks-lifecycle-m1-gap-c) is in flight
|
|
26
|
-
* and already modifies session.ts + tool-bridge + permission. Editing
|
|
27
|
-
* native-pugi.ts in this PR risks a merge conflict against α5.3's
|
|
28
|
-
* landing PR. Keeping the wiring as an exported helper means the
|
|
29
|
-
* one-line callsite in native-pugi.ts can be added in a tiny
|
|
30
|
-
* follow-up after both α5.3 and α5.5 have landed.
|
|
31
|
-
*
|
|
32
|
-
* Expected callsite in `apps/pugi-cli/src/core/engine/native-pugi.ts`,
|
|
33
|
-
* inside `onToolResult`:
|
|
34
|
-
*
|
|
35
|
-
* ```ts
|
|
36
|
-
* const compactionOutcome = await maybeCompactAfterTool({
|
|
37
|
-
* session,
|
|
38
|
-
* transcript: currentTranscript,
|
|
39
|
-
* toolOutputs: recentToolOutputs,
|
|
40
|
-
* contextBudgetUsed: estimatedTokens,
|
|
41
|
-
* contextBudgetMax: budget.maxTokens,
|
|
42
|
-
* workspaceRoot: root,
|
|
43
|
-
* contextStaticHash: {
|
|
44
|
-
* instructionsHash,
|
|
45
|
-
* toolSchemaHash,
|
|
46
|
-
* },
|
|
47
|
-
* });
|
|
48
|
-
* if (compactionOutcome.committed) {
|
|
49
|
-
* currentTranscript = compactionOutcome.newTranscript;
|
|
50
|
-
* }
|
|
51
|
-
* ```
|
|
52
|
-
*/
|
|
53
|
-
import { runCompaction, selectTier, } from '../context/compaction.js';
|
|
54
|
-
import { checkInvariants } from '../context/invariants.js';
|
|
55
|
-
import { emitCompactionCompleted, emitCompactionInvariantViolated, emitCompactionSkipped, emitCompactionStarted, } from '../context/compaction-events.js';
|
|
56
|
-
/**
|
|
57
|
-
* Engine-loop callback. See file header for the expected callsite shape.
|
|
58
|
-
*
|
|
59
|
-
* Contract:
|
|
60
|
-
* - Never throws. All errors degrade to `committed: false` with the
|
|
61
|
-
* original transcript and an event record.
|
|
62
|
-
* - On `committed: true`, the caller MUST adopt `newTranscript` as
|
|
63
|
-
* the live working transcript for the next model turn.
|
|
64
|
-
* - On `committed: false`, the caller MUST keep the input transcript
|
|
65
|
-
* and try again on the next tool turn (compaction will retry once
|
|
66
|
-
* pressure stays above threshold).
|
|
67
|
-
*/
|
|
68
|
-
export async function maybeCompactAfterTool(input) {
|
|
69
|
-
const compactionInput = {
|
|
70
|
-
sessionId: input.session.id,
|
|
71
|
-
contextBudgetUsed: input.contextBudgetUsed,
|
|
72
|
-
contextBudgetMax: input.contextBudgetMax,
|
|
73
|
-
toolOutputs: input.toolOutputs,
|
|
74
|
-
transcript: input.transcript,
|
|
75
|
-
workspaceRoot: input.workspaceRoot,
|
|
76
|
-
};
|
|
77
|
-
const tier = selectTier(compactionInput);
|
|
78
|
-
emitCompactionStarted(input.session, tier, {
|
|
79
|
-
budgetUsed: input.contextBudgetUsed,
|
|
80
|
-
budgetMax: input.contextBudgetMax,
|
|
81
|
-
});
|
|
82
|
-
let result;
|
|
83
|
-
try {
|
|
84
|
-
result = await runCompaction(compactionInput, tier);
|
|
85
|
-
}
|
|
86
|
-
catch (error) {
|
|
87
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
88
|
-
emitCompactionSkipped(input.session, tier, `compaction crashed: ${reason}`);
|
|
89
|
-
return {
|
|
90
|
-
committed: false,
|
|
91
|
-
tier,
|
|
92
|
-
newTranscript: input.transcript,
|
|
93
|
-
bytesReclaimed: 0,
|
|
94
|
-
newContextSize: byteSize(input.transcript),
|
|
95
|
-
violations: [],
|
|
96
|
-
skipped: true,
|
|
97
|
-
skipReason: `crashed: ${reason}`,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
if (result.skipped) {
|
|
101
|
-
emitCompactionSkipped(input.session, tier, result.skipReason || 'no work');
|
|
102
|
-
return {
|
|
103
|
-
committed: false,
|
|
104
|
-
tier,
|
|
105
|
-
newTranscript: input.transcript,
|
|
106
|
-
bytesReclaimed: 0,
|
|
107
|
-
newContextSize: byteSize(input.transcript),
|
|
108
|
-
violations: [],
|
|
109
|
-
skipped: true,
|
|
110
|
-
skipReason: result.skipReason,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
// Invariant gate: static-hash-unchanged is enforced by passing the
|
|
114
|
-
// same hashes in for `before` and `after` — compaction never touches
|
|
115
|
-
// static blocks, so the hashes are equal by construction. We pass
|
|
116
|
-
// both so the contract is explicit; if a future tier introduces a
|
|
117
|
-
// bug that overwrites static state, the check still catches it.
|
|
118
|
-
const violations = checkInvariants({
|
|
119
|
-
before: compactionInput,
|
|
120
|
-
after: result,
|
|
121
|
-
summaryText: result.summaryText,
|
|
122
|
-
staticHashBefore: input.contextStaticHash,
|
|
123
|
-
staticHashAfter: input.contextStaticHash,
|
|
124
|
-
});
|
|
125
|
-
if (violations.length > 0) {
|
|
126
|
-
for (const v of violations)
|
|
127
|
-
emitCompactionInvariantViolated(input.session, v);
|
|
128
|
-
return {
|
|
129
|
-
committed: false,
|
|
130
|
-
tier,
|
|
131
|
-
newTranscript: input.transcript,
|
|
132
|
-
bytesReclaimed: 0,
|
|
133
|
-
newContextSize: byteSize(input.transcript),
|
|
134
|
-
violations,
|
|
135
|
-
skipped: false,
|
|
136
|
-
skipReason: '',
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
emitCompactionCompleted(input.session, tier, result.bytesReclaimed, result.newContextSize, result.artifactsCreated);
|
|
140
|
-
return {
|
|
141
|
-
committed: true,
|
|
142
|
-
tier,
|
|
143
|
-
newTranscript: result.newTranscript,
|
|
144
|
-
bytesReclaimed: result.bytesReclaimed,
|
|
145
|
-
newContextSize: result.newContextSize,
|
|
146
|
-
violations: [],
|
|
147
|
-
skipped: false,
|
|
148
|
-
skipReason: '',
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
function byteSize(transcript) {
|
|
152
|
-
return transcript.reduce((sum, t) => sum + Buffer.byteLength(t.content, 'utf8'), 0);
|
|
153
|
-
}
|
|
154
|
-
//# sourceMappingURL=compaction-hook.js.map
|
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Workspace scaffold — extracted from `pugi init` so the bare REPL boot
|
|
3
|
-
* can call it automatically when the operator launches `pugi` in a
|
|
4
|
-
* fresh directory (CEO directive 2026-05-26).
|
|
5
|
-
*
|
|
6
|
-
* Before this module, `pugi init` was the only path that materialised
|
|
7
|
-
* `.pugi/` + the canonical config files. Launching the REPL in an empty
|
|
8
|
-
* directory printed `workspace: (not bound - run /init OR cd into
|
|
9
|
-
* project)` and instructed the operator to Ctrl+C, run `pugi init`,
|
|
10
|
-
* relaunch. That round trip is hostile on a first-touch install — CEO
|
|
11
|
-
* escalated "auto = решение" on 2026-05-26.
|
|
12
|
-
*
|
|
13
|
-
* The module is intentionally side-effect free at import time: the
|
|
14
|
-
* scaffold runs only when `ensureWorkspaceInitialized` is called. The
|
|
15
|
-
* scaffold is also idempotent — every file write is gated by an
|
|
16
|
-
* `existsSync` check, so re-running against a workspace that already has
|
|
17
|
-
* `.pugi/settings.json` (e.g. a manual `pugi init` followed by auto-init
|
|
18
|
-
* on next REPL launch) is a no-op. The function is safe to call before
|
|
19
|
-
* any other init logic.
|
|
20
|
-
*
|
|
21
|
-
* Two CRITICAL invariants:
|
|
22
|
-
*
|
|
23
|
-
* 1. **Atomic per-file.** Every write uses `existsSync` + `writeFileSync`
|
|
24
|
-
* against the final path. There is no read-modify-write pattern that
|
|
25
|
-
* could lose data on a concurrent `pugi init` race. The one path
|
|
26
|
-
* that DOES mutate an existing file — `.gitignore` (append `.pugi/`
|
|
27
|
-
* marker) — also gates on the marker being absent before appending,
|
|
28
|
-
* so the worst-case race is a duplicate marker line that the next
|
|
29
|
-
* run skips.
|
|
30
|
-
*
|
|
31
|
-
* 2. **Silent by default.** When `opts.silent` is true (the REPL
|
|
32
|
-
* auto-init path) the scaffold writes NOTHING to stderr/stdout.
|
|
33
|
-
* The REPL bootstrap runs before Ink mounts, and a stray
|
|
34
|
-
* stdout/stderr write at that point would land on the operator's
|
|
35
|
-
* shell ABOVE the alt-screen entry — visible until they scroll up,
|
|
36
|
-
* and noisy in a CI tail. The explicit `pugi init` path stays
|
|
37
|
-
* verbose via the standalone command in `runtime/cli.ts`.
|
|
38
|
-
*/
|
|
39
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
40
|
-
import { resolve } from 'node:path';
|
|
41
|
-
import { emptyIndex } from '../index-store.js';
|
|
42
|
-
/**
|
|
43
|
-
* Materialise the canonical `.pugi/` workspace scaffold under `cwd`.
|
|
44
|
-
* Returns a `{created, dir, createdPaths, skippedPaths}` summary so the
|
|
45
|
-
* caller can log a one-shot "initialized" line on the first call without
|
|
46
|
-
* re-checking the filesystem.
|
|
47
|
-
*
|
|
48
|
-
* The scaffold mirrors `pugi init` minus the bundled default-skills
|
|
49
|
-
* install (that is a heavier operation gated on the `--no-defaults`
|
|
50
|
-
* flag, and the standalone `pugi init` command keeps owning it).
|
|
51
|
-
*
|
|
52
|
-
* Idempotent: every file write gates on `existsSync`, so re-running
|
|
53
|
-
* against an existing workspace is a no-op and returns
|
|
54
|
-
* `{created: false}` with every path in `skippedPaths`.
|
|
55
|
-
*/
|
|
56
|
-
export function ensureWorkspaceInitialized(cwd, opts = {}) {
|
|
57
|
-
const silent = opts.silent !== false;
|
|
58
|
-
const pugiDir = resolve(cwd, '.pugi');
|
|
59
|
-
// Local trackers so the existing helpers (mkdirIfMissing /
|
|
60
|
-
// writeJsonIfMissing / writeTextIfMissing) keep their (created, skipped)
|
|
61
|
-
// signature. The explicit `pugi init` command forwards these straight
|
|
62
|
-
// into its JSON payload.
|
|
63
|
-
const created = [];
|
|
64
|
-
const skipped = [];
|
|
65
|
-
mkdirIfMissing(pugiDir, created, skipped);
|
|
66
|
-
mkdirIfMissing(resolve(pugiDir, 'artifacts'), created, skipped);
|
|
67
|
-
mkdirIfMissing(resolve(pugiDir, 'sessions'), created, skipped);
|
|
68
|
-
mkdirIfMissing(resolve(pugiDir, 'skills'), created, skipped);
|
|
69
|
-
writeJsonIfMissing(resolve(pugiDir, 'settings.json'), {
|
|
70
|
-
schema: 1,
|
|
71
|
-
workflow: {
|
|
72
|
-
brand: 'pugi',
|
|
73
|
-
legacyName: 'codeforge',
|
|
74
|
-
approvals: 'auto',
|
|
75
|
-
notAutomatic: [],
|
|
76
|
-
defaultBaseBranch: 'dev',
|
|
77
|
-
branchPrefixes: ['feature', 'fix', 'refactor', 'chore'],
|
|
78
|
-
aiCoAuthorTrailers: false,
|
|
79
|
-
},
|
|
80
|
-
permissions: {
|
|
81
|
-
mode: 'auto',
|
|
82
|
-
allow: [],
|
|
83
|
-
deny: [],
|
|
84
|
-
notAutomatic: [],
|
|
85
|
-
},
|
|
86
|
-
privacy: {
|
|
87
|
-
mode: 'balanced',
|
|
88
|
-
telemetry: 'off',
|
|
89
|
-
},
|
|
90
|
-
artifacts: {
|
|
91
|
-
defaultPath: '.pugi/artifacts',
|
|
92
|
-
promoteExplicitly: true,
|
|
93
|
-
},
|
|
94
|
-
}, created, skipped);
|
|
95
|
-
writeJsonIfMissing(resolve(pugiDir, 'mcp.json'), { schema: 1, servers: [] }, created, skipped);
|
|
96
|
-
writeJsonIfMissing(resolve(pugiDir, 'index.json'), emptyIndex(), created, skipped);
|
|
97
|
-
writeTextIfMissing(resolve(pugiDir, 'PUGI.md'), [
|
|
98
|
-
'# Pugi Project Context',
|
|
99
|
-
'',
|
|
100
|
-
'## Product Workflow',
|
|
101
|
-
'',
|
|
102
|
-
'- Public product name: Pugi',
|
|
103
|
-
'- Default flow: idea -> build -> review',
|
|
104
|
-
'- Approvals are automatic by default until a repo, environment, workflow, or action is marked notAutomatic.',
|
|
105
|
-
'- Do not add AI Co-Authored-By trailers.',
|
|
106
|
-
'- Generated code, comments, commits, PR text, and technical docs default to English.',
|
|
107
|
-
'',
|
|
108
|
-
'## Project Notes',
|
|
109
|
-
'',
|
|
110
|
-
'- Add repo-specific architecture, commands, and business rules here.',
|
|
111
|
-
'- Do not store secrets, real IPs, private key paths, tokens, or credentials here.',
|
|
112
|
-
'',
|
|
113
|
-
].join('\n'), created, skipped);
|
|
114
|
-
writeTextIfMissing(resolve(cwd, '.pugiignore'), [
|
|
115
|
-
'# Pugi ignore rules',
|
|
116
|
-
'.env',
|
|
117
|
-
'.env.*',
|
|
118
|
-
'!.env.example',
|
|
119
|
-
'node_modules/',
|
|
120
|
-
'dist/',
|
|
121
|
-
'.next/',
|
|
122
|
-
'coverage/',
|
|
123
|
-
'*.log',
|
|
124
|
-
'*.pem',
|
|
125
|
-
'*.key',
|
|
126
|
-
'*.crt',
|
|
127
|
-
'*.p12',
|
|
128
|
-
'*.sql',
|
|
129
|
-
'*.dump',
|
|
130
|
-
'',
|
|
131
|
-
].join('\n'), created, skipped);
|
|
132
|
-
ensurePugiGitIgnore(cwd, created, skipped);
|
|
133
|
-
// `silent` is honoured implicitly — this module never writes to
|
|
134
|
-
// stdout/stderr. The flag exists so the standalone `pugi init` command
|
|
135
|
-
// can layer its own logger on top (it does, in runtime/cli.ts), while
|
|
136
|
-
// the auto-init REPL path leaves the boot stream untouched. We
|
|
137
|
-
// reference the flag here to defeat the lint "unused" warning and to
|
|
138
|
-
// document the contract in the source.
|
|
139
|
-
void silent;
|
|
140
|
-
return {
|
|
141
|
-
created: created.length > 0,
|
|
142
|
-
dir: pugiDir,
|
|
143
|
-
createdPaths: created,
|
|
144
|
-
skippedPaths: skipped,
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
/* ------------------------------------------------------------------ */
|
|
148
|
-
/* Helpers (mirror the previous in-file implementations in cli.ts) */
|
|
149
|
-
/* ------------------------------------------------------------------ */
|
|
150
|
-
function mkdirIfMissing(path, created, skipped) {
|
|
151
|
-
if (existsSync(path)) {
|
|
152
|
-
skipped.push(path);
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
mkdirSync(path, { recursive: true });
|
|
156
|
-
created.push(path);
|
|
157
|
-
}
|
|
158
|
-
function writeJsonIfMissing(path, value, created, skipped) {
|
|
159
|
-
writeTextIfMissing(path, `${JSON.stringify(value, null, 2)}\n`, created, skipped);
|
|
160
|
-
}
|
|
161
|
-
function writeTextIfMissing(path, value, created, skipped) {
|
|
162
|
-
if (existsSync(path)) {
|
|
163
|
-
skipped.push(path);
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
writeFileSync(path, value, { encoding: 'utf8', mode: 0o600 });
|
|
167
|
-
created.push(path);
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* Ensure the workspace `.gitignore` ignores `.pugi/`. The function is
|
|
171
|
-
* additive: it leaves an existing `.gitignore` body intact and appends
|
|
172
|
-
* the marker only when none of `.pugi/`, `/.pugi/`, or `.pugi` is
|
|
173
|
-
* already present. On a fresh repo with no `.gitignore` it creates the
|
|
174
|
-
* file with the single marker line. Mode 0o600 matches the rest of the
|
|
175
|
-
* scaffold so a paranoid CI does not surface "world-readable" warnings.
|
|
176
|
-
*/
|
|
177
|
-
function ensurePugiGitIgnore(cwd, created, skipped) {
|
|
178
|
-
const gitignorePath = resolve(cwd, '.gitignore');
|
|
179
|
-
const marker = '.pugi/';
|
|
180
|
-
if (!existsSync(gitignorePath)) {
|
|
181
|
-
writeFileSync(gitignorePath, `${marker}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
182
|
-
created.push(gitignorePath);
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
const current = readFileSync(gitignorePath, 'utf8');
|
|
186
|
-
const lines = current.split('\n').map((line) => line.trim());
|
|
187
|
-
if (lines.includes(marker) || lines.includes('/.pugi/') || lines.includes('.pugi')) {
|
|
188
|
-
skipped.push(gitignorePath);
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
const next = current.endsWith('\n') ? `${current}${marker}\n` : `${current}\n${marker}\n`;
|
|
192
|
-
writeFileSync(gitignorePath, next, { encoding: 'utf8' });
|
|
193
|
-
created.push(`${gitignorePath} (+${marker})`);
|
|
194
|
-
}
|
|
195
|
-
//# sourceMappingURL=scaffold.js.map
|
|
@@ -1,297 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pugi CLI dual-write client spec — Memory Phase 1 (2026-05-27).
|
|
3
|
-
*
|
|
4
|
-
* Covers:
|
|
5
|
-
* 1. happy path — single batch posts to /api/pugi/sessions/:id/events
|
|
6
|
-
* 2. debounce + batch coalesces multiple enqueues into one POST
|
|
7
|
-
* 3. retry on transient failure with exponential backoff
|
|
8
|
-
* 4. surrender after maxRetries — local NDJSON stays authoritative
|
|
9
|
-
* 5. lastSyncedSeq marker is written + persisted across instances
|
|
10
|
-
* 6. flushBacklog catches up rows with seq > lastSyncedSeq
|
|
11
|
-
* 7. env kill-switch turns dual-write off
|
|
12
|
-
* 8. cliKindToPhase1 remap covers every CLI kind
|
|
13
|
-
* 9. invalid Phase-1 kinds quietly dropped from enqueue
|
|
14
|
-
* 10. dispose awaits the pending flush
|
|
15
|
-
*/
|
|
16
|
-
import { afterEach, beforeEach, describe, it } from 'node:test';
|
|
17
|
-
import assert from 'node:assert/strict';
|
|
18
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
19
|
-
import { tmpdir } from 'node:os';
|
|
20
|
-
import { resolve } from 'node:path';
|
|
21
|
-
import { DualWriteClient, cliKindToPhase1, debounceMsFromEnv, isDualWriteEnabledFromEnv, isValidPhase1Kind, nextBackoffMs, readSyncState, writeSyncState, } from './dual-write.js';
|
|
22
|
-
import { PUGI_SESSION_EVENT_KINDS, } from './phase1-kinds.js';
|
|
23
|
-
let tmpDir = '';
|
|
24
|
-
beforeEach(() => {
|
|
25
|
-
tmpDir = mkdtempSync(resolve(tmpdir(), 'pugi-dual-write-'));
|
|
26
|
-
});
|
|
27
|
-
afterEach(() => {
|
|
28
|
-
try {
|
|
29
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
30
|
-
}
|
|
31
|
-
catch {
|
|
32
|
-
/* ignore */
|
|
33
|
-
}
|
|
34
|
-
});
|
|
35
|
-
function makeStubFetch(responses) {
|
|
36
|
-
const calls = [];
|
|
37
|
-
let i = 0;
|
|
38
|
-
const fetchImpl = async (input, init) => {
|
|
39
|
-
const url = typeof input === 'string' ? input : input.toString();
|
|
40
|
-
const headers = {};
|
|
41
|
-
if (init?.headers) {
|
|
42
|
-
const h = init.headers;
|
|
43
|
-
for (const k of Object.keys(h))
|
|
44
|
-
headers[k.toLowerCase()] = h[k];
|
|
45
|
-
}
|
|
46
|
-
const bodyRaw = typeof init?.body === 'string' ? init.body : '';
|
|
47
|
-
let body = null;
|
|
48
|
-
try {
|
|
49
|
-
body = bodyRaw ? JSON.parse(bodyRaw) : null;
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
body = bodyRaw;
|
|
53
|
-
}
|
|
54
|
-
calls.push({ url, body, headers });
|
|
55
|
-
const spec = responses[i++] ?? responses[responses.length - 1] ?? { ok: true };
|
|
56
|
-
if (spec.throw)
|
|
57
|
-
throw spec.throw;
|
|
58
|
-
return {
|
|
59
|
-
ok: spec.ok,
|
|
60
|
-
status: spec.status ?? (spec.ok ? 200 : 500),
|
|
61
|
-
statusText: spec.ok ? 'OK' : 'Internal Server Error',
|
|
62
|
-
json: async () => spec.body ?? {
|
|
63
|
-
persistedCount: 0,
|
|
64
|
-
duplicateCount: 0,
|
|
65
|
-
lastSeq: 0,
|
|
66
|
-
},
|
|
67
|
-
};
|
|
68
|
-
};
|
|
69
|
-
return { fetchImpl, calls };
|
|
70
|
-
}
|
|
71
|
-
/* --------------------------------------------------------------- */
|
|
72
|
-
/* Pure helpers */
|
|
73
|
-
/* --------------------------------------------------------------- */
|
|
74
|
-
describe('dual-write: env helpers', () => {
|
|
75
|
-
it('isDualWriteEnabledFromEnv: defaults to ON', () => {
|
|
76
|
-
assert.equal(isDualWriteEnabledFromEnv({}), true);
|
|
77
|
-
assert.equal(isDualWriteEnabledFromEnv({ PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED: 'true' }), true);
|
|
78
|
-
});
|
|
79
|
-
it('isDualWriteEnabledFromEnv: OFF on common false-ish values', () => {
|
|
80
|
-
assert.equal(isDualWriteEnabledFromEnv({ PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED: 'false' }), false);
|
|
81
|
-
assert.equal(isDualWriteEnabledFromEnv({ PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED: '0' }), false);
|
|
82
|
-
assert.equal(isDualWriteEnabledFromEnv({ PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED: 'off' }), false);
|
|
83
|
-
});
|
|
84
|
-
it('debounceMsFromEnv: parses + clamps + falls back', () => {
|
|
85
|
-
assert.equal(debounceMsFromEnv({}), 250);
|
|
86
|
-
assert.equal(debounceMsFromEnv({ PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS: '500' }), 500);
|
|
87
|
-
// Bogus -> default.
|
|
88
|
-
assert.equal(debounceMsFromEnv({ PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS: 'abc' }), 250);
|
|
89
|
-
// Negative -> default.
|
|
90
|
-
assert.equal(debounceMsFromEnv({ PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS: '-5' }), 250);
|
|
91
|
-
// Too large -> default.
|
|
92
|
-
assert.equal(debounceMsFromEnv({ PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS: '120000' }), 250);
|
|
93
|
-
});
|
|
94
|
-
it('nextBackoffMs: 1x / 3x / 9x growth', () => {
|
|
95
|
-
assert.equal(nextBackoffMs(1, 100), 100);
|
|
96
|
-
assert.equal(nextBackoffMs(2, 100), 300);
|
|
97
|
-
assert.equal(nextBackoffMs(3, 100), 900);
|
|
98
|
-
});
|
|
99
|
-
it('cliKindToPhase1: covers every CLI kind we know about', () => {
|
|
100
|
-
assert.equal(cliKindToPhase1('user'), 'turn.user');
|
|
101
|
-
assert.equal(cliKindToPhase1('persona'), 'turn.assistant');
|
|
102
|
-
assert.equal(cliKindToPhase1('system'), 'system');
|
|
103
|
-
assert.equal(cliKindToPhase1('tool.start'), 'tool.call');
|
|
104
|
-
assert.equal(cliKindToPhase1('tool.result'), 'tool.result');
|
|
105
|
-
assert.equal(cliKindToPhase1('agent.spawned'), 'dispatch.start');
|
|
106
|
-
assert.equal(cliKindToPhase1('agent.completed'), 'dispatch.end');
|
|
107
|
-
assert.equal(cliKindToPhase1('compaction'), 'compact.boundary');
|
|
108
|
-
// rewind-marker and any other -> null (the CLI keeps these in NDJSON only).
|
|
109
|
-
assert.equal(cliKindToPhase1('rewind-marker'), null);
|
|
110
|
-
assert.equal(cliKindToPhase1('totally-unknown'), null);
|
|
111
|
-
});
|
|
112
|
-
it('isValidPhase1Kind matches the closed set', () => {
|
|
113
|
-
for (const k of PUGI_SESSION_EVENT_KINDS) {
|
|
114
|
-
assert.equal(isValidPhase1Kind(k), true);
|
|
115
|
-
}
|
|
116
|
-
assert.equal(isValidPhase1Kind('user'), false); // CLI kind, not Phase-1
|
|
117
|
-
assert.equal(isValidPhase1Kind('nope'), false);
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
/* --------------------------------------------------------------- */
|
|
121
|
-
/* lastSyncedSeq marker */
|
|
122
|
-
/* --------------------------------------------------------------- */
|
|
123
|
-
describe('dual-write: sync state marker', () => {
|
|
124
|
-
it('reads 0 when no marker on disk', () => {
|
|
125
|
-
assert.equal(readSyncState('sess-1', tmpDir), 0);
|
|
126
|
-
});
|
|
127
|
-
it('writes + reads back monotonic seq', () => {
|
|
128
|
-
writeSyncState('sess-1', 42, tmpDir);
|
|
129
|
-
assert.equal(readSyncState('sess-1', tmpDir), 42);
|
|
130
|
-
writeSyncState('sess-1', 100, tmpDir);
|
|
131
|
-
assert.equal(readSyncState('sess-1', tmpDir), 100);
|
|
132
|
-
});
|
|
133
|
-
it('isolates per-session', () => {
|
|
134
|
-
writeSyncState('sess-a', 5, tmpDir);
|
|
135
|
-
writeSyncState('sess-b', 10, tmpDir);
|
|
136
|
-
assert.equal(readSyncState('sess-a', tmpDir), 5);
|
|
137
|
-
assert.equal(readSyncState('sess-b', tmpDir), 10);
|
|
138
|
-
});
|
|
139
|
-
});
|
|
140
|
-
/* --------------------------------------------------------------- */
|
|
141
|
-
/* DualWriteClient end-to-end */
|
|
142
|
-
/* --------------------------------------------------------------- */
|
|
143
|
-
describe('DualWriteClient', () => {
|
|
144
|
-
it('flushes a single batch on demand', async () => {
|
|
145
|
-
const { fetchImpl, calls } = makeStubFetch([
|
|
146
|
-
{ ok: true, body: { persistedCount: 2, duplicateCount: 0, lastSeq: 2 } },
|
|
147
|
-
]);
|
|
148
|
-
const client = new DualWriteClient({
|
|
149
|
-
apiUrl: 'http://localhost',
|
|
150
|
-
apiKey: 'test-key',
|
|
151
|
-
sessionId: 'sess-1',
|
|
152
|
-
fetchImpl,
|
|
153
|
-
debounceMs: 5,
|
|
154
|
-
stateDir: tmpDir,
|
|
155
|
-
});
|
|
156
|
-
client.enqueue({ seq: 1, kind: 'turn.user', payload: { text: 'hi' } });
|
|
157
|
-
client.enqueue({ seq: 2, kind: 'turn.assistant', payload: { text: 'hello' } });
|
|
158
|
-
const result = await client.flush();
|
|
159
|
-
await client.dispose();
|
|
160
|
-
assert.equal(calls.length, 1);
|
|
161
|
-
assert.equal(calls[0].headers['authorization'], 'Bearer test-key');
|
|
162
|
-
const sentBody = calls[0].body;
|
|
163
|
-
assert.equal(sentBody.events.length, 2);
|
|
164
|
-
assert.equal(sentBody.events[0].seq, 1);
|
|
165
|
-
assert.equal(sentBody.events[1].kind, 'turn.assistant');
|
|
166
|
-
assert.equal(result?.persistedCount, 2);
|
|
167
|
-
assert.equal(result?.lastSeq, 2);
|
|
168
|
-
// Marker persisted.
|
|
169
|
-
assert.equal(readSyncState('sess-1', tmpDir), 2);
|
|
170
|
-
});
|
|
171
|
-
it('retries on transient failure with exponential backoff', async () => {
|
|
172
|
-
const { fetchImpl, calls } = makeStubFetch([
|
|
173
|
-
{ ok: false, status: 503, body: {} },
|
|
174
|
-
{ ok: false, status: 503, body: {} },
|
|
175
|
-
{ ok: true, body: { persistedCount: 1, duplicateCount: 0, lastSeq: 5 } },
|
|
176
|
-
]);
|
|
177
|
-
const client = new DualWriteClient({
|
|
178
|
-
apiUrl: 'http://localhost',
|
|
179
|
-
apiKey: 'k',
|
|
180
|
-
sessionId: 'sess-r',
|
|
181
|
-
fetchImpl,
|
|
182
|
-
debounceMs: 1, // base for backoff -> 1ms / 3ms / 9ms; fast test
|
|
183
|
-
maxRetries: 3,
|
|
184
|
-
stateDir: tmpDir,
|
|
185
|
-
});
|
|
186
|
-
client.enqueue({ seq: 5, kind: 'turn.user', payload: {} });
|
|
187
|
-
const result = await client.flush();
|
|
188
|
-
await client.dispose();
|
|
189
|
-
assert.equal(calls.length, 3);
|
|
190
|
-
assert.equal(result?.lastSeq, 5);
|
|
191
|
-
assert.equal(readSyncState('sess-r', tmpDir), 5);
|
|
192
|
-
});
|
|
193
|
-
it('surrenders after maxRetries — local NDJSON stays the truth', async () => {
|
|
194
|
-
const { fetchImpl, calls } = makeStubFetch([
|
|
195
|
-
{ ok: false, status: 500, body: {} },
|
|
196
|
-
{ ok: false, status: 500, body: {} },
|
|
197
|
-
{ ok: false, status: 500, body: {} },
|
|
198
|
-
]);
|
|
199
|
-
const client = new DualWriteClient({
|
|
200
|
-
apiUrl: 'http://localhost',
|
|
201
|
-
apiKey: 'k',
|
|
202
|
-
sessionId: 'sess-fail',
|
|
203
|
-
fetchImpl,
|
|
204
|
-
debounceMs: 1,
|
|
205
|
-
maxRetries: 3,
|
|
206
|
-
stateDir: tmpDir,
|
|
207
|
-
});
|
|
208
|
-
client.enqueue({ seq: 1, kind: 'turn.user', payload: {} });
|
|
209
|
-
const result = await client.flush();
|
|
210
|
-
await client.dispose();
|
|
211
|
-
assert.equal(calls.length, 3);
|
|
212
|
-
assert.equal(result, null); // surrender
|
|
213
|
-
// Marker NOT written — next session retries from zero.
|
|
214
|
-
assert.equal(readSyncState('sess-fail', tmpDir), 0);
|
|
215
|
-
});
|
|
216
|
-
it('drops events whose kind is not in the Phase-1 set', async () => {
|
|
217
|
-
const { fetchImpl, calls } = makeStubFetch([
|
|
218
|
-
{ ok: true, body: { persistedCount: 1, duplicateCount: 0, lastSeq: 1 } },
|
|
219
|
-
]);
|
|
220
|
-
const client = new DualWriteClient({
|
|
221
|
-
apiUrl: 'http://localhost',
|
|
222
|
-
apiKey: 'k',
|
|
223
|
-
sessionId: 'sess-d',
|
|
224
|
-
fetchImpl,
|
|
225
|
-
debounceMs: 5,
|
|
226
|
-
stateDir: tmpDir,
|
|
227
|
-
});
|
|
228
|
-
// The string is broader than Phase-1 — the client filters silently.
|
|
229
|
-
client.enqueue({ seq: 1, kind: 'rewind-marker', payload: {} });
|
|
230
|
-
client.enqueue({ seq: 2, kind: 'turn.user', payload: {} });
|
|
231
|
-
await client.flush();
|
|
232
|
-
await client.dispose();
|
|
233
|
-
const sent = calls[0].body;
|
|
234
|
-
assert.equal(sent.events.length, 1);
|
|
235
|
-
assert.equal(sent.events[0].seq, 2);
|
|
236
|
-
});
|
|
237
|
-
it('flushBacklog skips events <= lastSyncedSeq and posts the rest', async () => {
|
|
238
|
-
const { fetchImpl, calls } = makeStubFetch([
|
|
239
|
-
{ ok: true, body: { persistedCount: 2, duplicateCount: 0, lastSeq: 10 } },
|
|
240
|
-
]);
|
|
241
|
-
// Seed a marker: last synced was seq=5.
|
|
242
|
-
writeSyncState('sess-bk', 5, tmpDir);
|
|
243
|
-
const client = new DualWriteClient({
|
|
244
|
-
apiUrl: 'http://localhost',
|
|
245
|
-
apiKey: 'k',
|
|
246
|
-
sessionId: 'sess-bk',
|
|
247
|
-
fetchImpl,
|
|
248
|
-
stateDir: tmpDir,
|
|
249
|
-
});
|
|
250
|
-
const allEvents = [
|
|
251
|
-
{ seq: 1, kind: 'turn.user', payload: {} }, // skip
|
|
252
|
-
{ seq: 5, kind: 'turn.assistant', payload: {} }, // skip (<=5)
|
|
253
|
-
{ seq: 7, kind: 'turn.user', payload: {} }, // POST
|
|
254
|
-
{ seq: 10, kind: 'turn.assistant', payload: {} }, // POST
|
|
255
|
-
];
|
|
256
|
-
const result = await client.flushBacklog(allEvents);
|
|
257
|
-
assert.equal(calls.length, 1);
|
|
258
|
-
const sent = calls[0].body;
|
|
259
|
-
assert.equal(sent.events.length, 2);
|
|
260
|
-
assert.equal(sent.events[0].seq, 7);
|
|
261
|
-
assert.equal(sent.events[1].seq, 10);
|
|
262
|
-
assert.equal(result.persistedCount, 2);
|
|
263
|
-
assert.equal(result.lastSeq, 10);
|
|
264
|
-
assert.equal(readSyncState('sess-bk', tmpDir), 10);
|
|
265
|
-
await client.dispose();
|
|
266
|
-
});
|
|
267
|
-
it('dispose awaits the in-flight flush', async () => {
|
|
268
|
-
// Use a fetch that resolves after a tiny delay so the dispose has to
|
|
269
|
-
// wait.
|
|
270
|
-
let resolved = false;
|
|
271
|
-
const fetchImpl = async () => {
|
|
272
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
273
|
-
resolved = true;
|
|
274
|
-
return {
|
|
275
|
-
ok: true,
|
|
276
|
-
status: 200,
|
|
277
|
-
statusText: 'OK',
|
|
278
|
-
json: async () => ({ persistedCount: 1, duplicateCount: 0, lastSeq: 1 }),
|
|
279
|
-
};
|
|
280
|
-
};
|
|
281
|
-
const client = new DualWriteClient({
|
|
282
|
-
apiUrl: 'http://localhost',
|
|
283
|
-
apiKey: 'k',
|
|
284
|
-
sessionId: 'sess-disp',
|
|
285
|
-
fetchImpl,
|
|
286
|
-
debounceMs: 1,
|
|
287
|
-
stateDir: tmpDir,
|
|
288
|
-
});
|
|
289
|
-
client.enqueue({ seq: 1, kind: 'turn.user', payload: {} });
|
|
290
|
-
// Schedule a flush + immediately dispose; dispose must wait.
|
|
291
|
-
const flushPromise = client.flush();
|
|
292
|
-
await client.dispose();
|
|
293
|
-
await flushPromise;
|
|
294
|
-
assert.equal(resolved, true);
|
|
295
|
-
});
|
|
296
|
-
});
|
|
297
|
-
//# sourceMappingURL=dual-write.spec.js.map
|