@cspeach/cli 1.0.0 → 1.1.1

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.
Files changed (100) hide show
  1. package/dist/agent/loop.js +22 -9
  2. package/dist/approvals/op-labels.js +124 -0
  3. package/dist/approvals/render.js +42 -36
  4. package/dist/cli.js +15 -0
  5. package/dist/commands/compact.js +28 -2
  6. package/dist/commands/config-set.js +189 -0
  7. package/dist/commands/config-show.js +20 -0
  8. package/dist/commands/export-audit.js +43 -0
  9. package/dist/commands/help.js +5 -0
  10. package/dist/commands/plan-audit-evidence.js +266 -0
  11. package/dist/commands/plan-audit.js +692 -0
  12. package/dist/commands/plan-chain.js +671 -0
  13. package/dist/commands/plan-continue.js +179 -0
  14. package/dist/commands/plan-gate.js +154 -0
  15. package/dist/commands/plan-resume.js +588 -33
  16. package/dist/config/loader.js +128 -4
  17. package/dist/config/model-defaults.js +14 -0
  18. package/dist/cost/pricing.js +27 -1
  19. package/dist/doctor/checks/system-roles.js +41 -0
  20. package/dist/doctor/run.js +2 -0
  21. package/dist/models/resolve.js +61 -0
  22. package/dist/models/server-config.js +155 -0
  23. package/dist/one-shot.js +25 -3
  24. package/dist/projects/extract-cca.js +3 -1
  25. package/dist/projects/extract-modernize.js +3 -1
  26. package/dist/projects/extract-plan.js +60 -6
  27. package/dist/projects/extract-test-coverage.js +3 -1
  28. package/dist/projects/extract-upgrade.js +3 -1
  29. package/dist/projects/handover-md.js +195 -0
  30. package/dist/projects/index.js +1 -1
  31. package/dist/projects/plan-run.js +137 -13
  32. package/dist/projects/plan-schema.js +73 -0
  33. package/dist/projects/run-lease.js +157 -0
  34. package/dist/projects/save-command.js +26 -15
  35. package/dist/renderer/status-footer.js +22 -12
  36. package/dist/renderer/thinking-heartbeat.js +64 -8
  37. package/dist/renderer/todo-block.js +51 -0
  38. package/dist/renderer/tool-widget.js +37 -0
  39. package/dist/repl/bracketed-paste.js +28 -19
  40. package/dist/repl/builtin-commands.js +5 -0
  41. package/dist/repl/current-transport.js +10 -0
  42. package/dist/repl/history.js +86 -0
  43. package/dist/repl/ink-stdin-guard.js +64 -0
  44. package/dist/repl/mode-ceiling.js +16 -0
  45. package/dist/repl/mode-cycle.js +104 -0
  46. package/dist/repl/post-turn-status.js +24 -4
  47. package/dist/repl/slash-completer.js +5 -0
  48. package/dist/repl.js +954 -83
  49. package/dist/rewind/candidates.js +194 -0
  50. package/dist/rewind/cli.js +137 -0
  51. package/dist/rewind/format.js +27 -0
  52. package/dist/rewind/restore.js +245 -0
  53. package/dist/session/audit-export.js +459 -0
  54. package/dist/session/context-report.js +163 -0
  55. package/dist/session/recap.js +160 -0
  56. package/dist/skill-catalog.js +9 -3
  57. package/dist/skills/bundled-skills.js +71 -78
  58. package/dist/tools/approval.js +115 -7
  59. package/dist/tools/ask-question.js +304 -3
  60. package/dist/tools/extend-model/anchored-insert.js +604 -0
  61. package/dist/tools/extend-model/tool.js +162 -10
  62. package/dist/tools/fiori/fe-extend.js +76 -0
  63. package/dist/tools/fiori/fe-scaffold.js +29 -3
  64. package/dist/tools/fiori/floorplan-map.js +19 -0
  65. package/dist/tools/fiori/samples/data/index.json +13602 -0
  66. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  67. package/dist/tools/fiori/samples/loader.js +248 -0
  68. package/dist/tools/fiori/samples/search.js +63 -0
  69. package/dist/tools/fiori/samples/types.js +2 -0
  70. package/dist/tools/fiori/smoke/assertions.js +74 -0
  71. package/dist/tools/fiori/smoke/browser.js +52 -0
  72. package/dist/tools/fiori/smoke/driver.js +89 -0
  73. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  74. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  75. package/dist/tools/fiori/tools.js +328 -3
  76. package/dist/tools/local-build.js +11 -1
  77. package/dist/tools/sap-read.js +79 -11
  78. package/dist/tools/sap-write.js +24 -4
  79. package/dist/tools/snapshot.js +27 -1
  80. package/dist/tools/subagent/agent_run.js +27 -3
  81. package/dist/tools/todo.js +144 -0
  82. package/dist/ui/app.js +372 -19
  83. package/dist/ui/approval-modal.js +49 -16
  84. package/dist/ui/ask-question-emitter.js +14 -0
  85. package/dist/ui/context-grid.js +108 -0
  86. package/dist/ui/footer.js +109 -30
  87. package/dist/ui/header.js +7 -0
  88. package/dist/ui/line-resolution.js +18 -2
  89. package/dist/ui/rewind-emitter.js +10 -0
  90. package/dist/ui/rewind-panel.js +81 -0
  91. package/dist/ui/sap-state-store.js +1 -0
  92. package/dist/ui/status-line.js +43 -0
  93. package/dist/ui/text-input.js +72 -8
  94. package/dist/ui/todo-emitter.js +25 -0
  95. package/dist/ui/todo-panel.js +64 -0
  96. package/dist/ui/turn-status-emitter.js +50 -4
  97. package/dist/ui/turn-status.js +18 -3
  98. package/dist/ui/widgets/ask-form.js +242 -0
  99. package/dist/ui/widgets/ask-question-modal.js +17 -7
  100. package/package.json +4 -1
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Rewind data layer (pure) — UX Wave 3, Task 1.
3
+ *
4
+ * Answers the question that powers the "undo for your SAP system" feature:
5
+ * *which of this session's successful writes can be rolled back, and to what
6
+ * prior state?* It joins the session's write tool-calls with the Rule-7
7
+ * snapshots taken just before each write.
8
+ *
9
+ * Pure module: no fs, no network. The snapshot list arrives as an argument
10
+ * (from `snapshots.list()`), so this function is trivially testable and can
11
+ * run anywhere.
12
+ *
13
+ * Scope (binding, from the Task-1 brief):
14
+ * - Five write tools qualify: sap_set_source, sap_update_method,
15
+ * sap_set_class_include, sap_create_object, sap_delete_object.
16
+ * - Only successful (`is_error: false`) AND completed (`completed: true`)
17
+ * calls count — pending entries and failures are ignored.
18
+ * - Newest write first; deduped per (object, objType) keeping the NEWEST
19
+ * write for that object.
20
+ * - The restore point is the newest snapshot taken strictly BEFORE that
21
+ * write (snapshot timestamp < write completed_at).
22
+ * - sap_create_object ⇒ not restorable (no prior state; restore of a create
23
+ * would be a delete, out of v1 scope) + a display `reason`.
24
+ * - sap_delete_object ⇒ restorable when a pre-delete snapshot exists (the
25
+ * snapshot IS the prior source; the executor recreates it).
26
+ */
27
+ /** The five write tools whose effects can (potentially) be rewound. */
28
+ const WRITE_TOOLS = new Set([
29
+ 'sap_set_source',
30
+ 'sap_update_method',
31
+ 'sap_set_class_include',
32
+ 'sap_create_object',
33
+ 'sap_delete_object',
34
+ ]);
35
+ /** Tools whose object is always a class, identified via className/class_name
36
+ * (they carry no explicit `type` arg — the type is implicitly CLAS). */
37
+ const IMPLICIT_CLASS_TOOLS = new Set([
38
+ 'sap_update_method',
39
+ 'sap_set_class_include',
40
+ ]);
41
+ /**
42
+ * Collect this session's rewindable writes, newest-first, one per object.
43
+ *
44
+ * @param toolCalls the session's tool-call ledger (SessionState.toolCalls)
45
+ * @param snapshotList all snapshots from snapshots.list() (no filter)
46
+ */
47
+ export function collectRewindCandidates(toolCalls, snapshotList) {
48
+ // 1. Keep only successful, completed calls to the five write tools that
49
+ // carry a resolvable object identity.
50
+ const writes = [];
51
+ for (const call of toolCalls) {
52
+ if (!call.completed || call.is_error)
53
+ continue;
54
+ if (!WRITE_TOOLS.has(call.tool))
55
+ continue;
56
+ const identity = extractIdentity(call.tool, call.args);
57
+ if (!identity)
58
+ continue;
59
+ writes.push({
60
+ call,
61
+ object: identity.object,
62
+ objType: identity.objType,
63
+ at: Date.parse(call.completed_at),
64
+ });
65
+ }
66
+ // 2. Newest write first.
67
+ writes.sort((a, b) => b.at - a.at);
68
+ // 3. Dedupe per (object, objType): the first survivor is the newest write.
69
+ const seen = new Set();
70
+ const candidates = [];
71
+ for (const w of writes) {
72
+ const key = `${w.objType}|${w.object}`;
73
+ if (seen.has(key))
74
+ continue;
75
+ seen.add(key);
76
+ const snap = newestSnapshotBefore(snapshotList, w.objType, w.object, w.at);
77
+ const isCreate = w.call.tool === 'sap_create_object';
78
+ // Creates force snapshotId/snapshotAt to null even when an older snapshot
79
+ // for the same (type, name) exists. Deliberate v1 scope, NOT a bug: in a
80
+ // delete→recreate-same-session sequence the recreate (newest write) wins
81
+ // the dedupe, so the pre-delete restore point is hidden — restoring
82
+ // pre-delete source onto a freshly created object is a cross-lifecycle
83
+ // rollback v1 does not attempt. Tasks 2/3: do not "fix" this.
84
+ const candidate = {
85
+ object: w.object,
86
+ objType: w.objType,
87
+ writeTool: w.call.tool,
88
+ writtenAt: w.call.completed_at,
89
+ snapshotId: isCreate ? null : (snap ? snap.id : null),
90
+ snapshotAt: isCreate ? null : (snap ? normalizeSnapshotTimestamp(snap.timestamp) : null),
91
+ restorable: !isCreate && snap !== null,
92
+ };
93
+ if (isCreate)
94
+ candidate.reason = 'created this session — no prior state';
95
+ // T1 (rider c) — mine the recreate package for delete-restores only.
96
+ if (w.call.tool === 'sap_delete_object') {
97
+ const pkg = harvestCreatePackage(toolCalls, w.objType, w.object, w.at);
98
+ if (pkg)
99
+ candidate.package = pkg;
100
+ }
101
+ candidates.push(candidate);
102
+ }
103
+ return candidates;
104
+ }
105
+ /**
106
+ * Resolve (object, objType) from a write tool's recorded args.
107
+ *
108
+ * Mirrors the retry-key heuristic (name/type with object_name/object_type
109
+ * alternates). The class-scoped tools are handled tool-conditionally for
110
+ * self-documentation: only sap_update_method (class_name) and
111
+ * sap_set_class_include (className) consult the class-name fields, and
112
+ * their type is implicitly CLAS (their schemas carry no `type` arg).
113
+ * Returns null when no object name can be resolved.
114
+ */
115
+ function extractIdentity(tool, rawArgs) {
116
+ const args = (rawArgs ?? {});
117
+ if (IMPLICIT_CLASS_TOOLS.has(tool)) {
118
+ const object = pickString(args, 'className') ?? pickString(args, 'class_name');
119
+ return object ? { object: object.toUpperCase(), objType: 'CLAS' } : null;
120
+ }
121
+ const object = pickString(args, 'name') ?? pickString(args, 'object_name');
122
+ if (!object)
123
+ return null;
124
+ const objType = pickString(args, 'type') ?? pickString(args, 'object_type');
125
+ if (!objType)
126
+ return null;
127
+ return { object: object.toUpperCase(), objType: objType.toUpperCase() };
128
+ }
129
+ /**
130
+ * T1 (rider c) — newest package from a successful sap_create_object of the
131
+ * same (type, name) completed strictly before the delete. Chronological ledger
132
+ * scan; returns undefined when this session never created the object.
133
+ */
134
+ function harvestCreatePackage(toolCalls, objType, object, beforeEpoch) {
135
+ let best;
136
+ for (const call of toolCalls) {
137
+ if (!call.completed || call.is_error)
138
+ continue;
139
+ if (call.tool !== 'sap_create_object')
140
+ continue;
141
+ const id = extractIdentity('sap_create_object', call.args);
142
+ if (!id || id.objType !== objType || id.object !== object)
143
+ continue;
144
+ const at = Date.parse(call.completed_at);
145
+ if (!Number.isFinite(at) || at >= beforeEpoch)
146
+ continue;
147
+ const pkg = pickString((call.args ?? {}), 'package');
148
+ if (!pkg)
149
+ continue;
150
+ if (!best || at > best.at)
151
+ best = { at, pkg };
152
+ }
153
+ return best?.pkg;
154
+ }
155
+ /** Newest snapshot for (type, name) taken strictly before `beforeEpoch`. */
156
+ function newestSnapshotBefore(snapshotList, objType, object, beforeEpoch) {
157
+ let best = null;
158
+ let bestEpoch = -Infinity;
159
+ for (const s of snapshotList) {
160
+ if (s.type.toUpperCase() !== objType)
161
+ continue;
162
+ if (s.name.toUpperCase() !== object)
163
+ continue;
164
+ const epoch = snapshotEpoch(s.timestamp);
165
+ if (!Number.isFinite(epoch))
166
+ continue;
167
+ if (epoch >= beforeEpoch)
168
+ continue; // must be strictly before the write
169
+ if (epoch > bestEpoch) {
170
+ bestEpoch = epoch;
171
+ best = s;
172
+ }
173
+ }
174
+ return best;
175
+ }
176
+ /**
177
+ * SnapshotEntry.timestamp is `new Date().toISOString().replace(/[:.]/g, '-')`
178
+ * (sap-client/src/safety/snapshots.ts), i.e. `YYYY-MM-DDTHH-MM-SS-mmmZ`. Turn
179
+ * it back into a real ISO string for display / parsing.
180
+ */
181
+ function normalizeSnapshotTimestamp(mangled) {
182
+ const m = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/.exec(mangled);
183
+ if (!m)
184
+ return mangled; // unexpected format — return as-is rather than lie
185
+ return `${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z`;
186
+ }
187
+ /** Epoch millis for a (possibly dash-mangled) snapshot timestamp. */
188
+ function snapshotEpoch(mangled) {
189
+ return Date.parse(normalizeSnapshotTimestamp(mangled));
190
+ }
191
+ function pickString(args, key) {
192
+ const v = args[key];
193
+ return typeof v === 'string' && v.length > 0 ? v : null;
194
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Rewind wiring — UX Wave 3 / Task 3. Three exports:
3
+ *
4
+ * - buildRestoreDeps(): the ONE shared RestoreDeps builder used by BOTH the
5
+ * Ink panel-restore path (repl.tsx) and the classic list path below. It
6
+ * resolves the transport PER-CANDIDATE at restore time (rider a — owning-TR
7
+ * override wins over the session transport, mirroring sap_set_source) and
8
+ * threads the alias auto_approve policy (rider b) so the modal's risk label
9
+ * is true.
10
+ * - executeRestore(): the shared restore leg — single-flight reserve (a
11
+ * restore counts as BUSY: submits become steering, dispatches queue),
12
+ * deps assembly, the gated executor, and outcome printing. Catches every
13
+ * throw (defense in depth for review I2) so a network drop mid-pipeline
14
+ * prints the partial receipt + error instead of crashing the process.
15
+ * - runClassicRewind(): the non-Ink `/rewind` — a numbered inquirer list +
16
+ * confirm, reusing the identical candidate collector and executeRestore.
17
+ */
18
+ import chalk from 'chalk';
19
+ import { select, confirm } from '@inquirer/prompts';
20
+ import { snapshots } from '@cspeach/sap-client';
21
+ import { collectRewindCandidates } from './candidates.js';
22
+ import { restoreSnapshot } from './restore.js';
23
+ import { hhmm, rewindRowLabel, packageUnknown } from './format.js';
24
+ // Pure singleton emitter (no Ink) — safe in the classic path. In classic mode
25
+ // nothing consults isBusy() during an awaited builtin, so the reserve is a
26
+ // no-op there; in Ink it is the whole point (see executeRestore).
27
+ import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
28
+ import { resolveWriteTransport } from '../tools/transport-resolution.js';
29
+ import { renderPerChangeApproval } from '../approvals/render.js';
30
+ import { withInquirer } from '../repl/inquirer-guard.js';
31
+ import { inquirerTheme } from '../repl/inquirer-theme.js';
32
+ /**
33
+ * Assemble RestoreDeps for one candidate. Resolves the write transport against
34
+ * the owning-transport ledger (rider a) and threads the alias auto_approve
35
+ * policy (rider b). Called once per restore so a per-object owning-TR override
36
+ * is always honoured.
37
+ */
38
+ export async function buildRestoreDeps(c, w) {
39
+ const sap = w.cfg.sap[w.alias];
40
+ const resolved = await resolveWriteTransport(w.toolCtx, c.objType, c.object, w.suppliedTransport);
41
+ return {
42
+ adt: w.adt,
43
+ cfgWriteMode: w.cfg.write_mode,
44
+ role: sap?.role,
45
+ transport: resolved.transport,
46
+ autoApprovePolicy: sap?.auto_approve ?? 'never',
47
+ requestApproval: w.requestApproval,
48
+ log: w.log,
49
+ };
50
+ }
51
+ /**
52
+ * The shared restore leg (review I1a + I2 + M4). Both surfaces funnel here:
53
+ *
54
+ * - I1(a): turnStatusEmitter.reserve() is held for the FULL duration — the
55
+ * pre-approval window (transport lookup, snapshot read, current-source
56
+ * read), the pending approval modal, and the post-approval pipeline. While
57
+ * held, isBusy() is true, so a footer submit becomes queued steering and no
58
+ * turn can dispatch mid-restore (the model could otherwise write over the
59
+ * object we are restoring). release() in finally — a thrown pipeline can
60
+ * never wedge the session busy.
61
+ * - I2 (defense in depth): restoreSnapshot itself now catches its pipeline
62
+ * awaits, but ANY residual throw (deps assembly, a future refactor) lands
63
+ * in the catch below and prints the partial receipt context + error instead
64
+ * of escaping to the process-level uncaughtException exit.
65
+ * - M4: a declined approval prints a calm '↩ rewind cancelled' line — the
66
+ * user said no; nothing failed.
67
+ */
68
+ export async function executeRestore(c, w, print) {
69
+ turnStatusEmitter.reserve();
70
+ try {
71
+ const deps = await buildRestoreDeps(c, w);
72
+ const r = await restoreSnapshot(c, deps);
73
+ if (r.ok) {
74
+ print(chalk.green(`✓ restored ${c.objType}/${c.object}`));
75
+ }
76
+ else if (r.error === 'declined') {
77
+ print(chalk.dim(`↩ rewind cancelled — approval declined`));
78
+ }
79
+ else {
80
+ print(chalk.red(`✗ rewind failed — ${r.error ?? 'unknown'}`));
81
+ }
82
+ }
83
+ catch (err) {
84
+ // Partial receipt lines already streamed via deps.log — close honestly.
85
+ const msg = err instanceof Error ? err.message : String(err);
86
+ print(chalk.red(`✗ rewind failed — ${msg}`));
87
+ }
88
+ finally {
89
+ turnStatusEmitter.release();
90
+ }
91
+ }
92
+ /**
93
+ * Classic-mode `/rewind`: numbered list → inquirer select (non-restorable rows
94
+ * disabled) → confirm → gated restore → receipt. Same candidates + executor as
95
+ * the Ink panel; only the surface differs.
96
+ */
97
+ export async function runClassicRewind(ctx) {
98
+ const snapshotList = await snapshots.list();
99
+ const candidates = collectRewindCandidates(ctx.toolCalls, snapshotList);
100
+ if (candidates.length === 0) {
101
+ ctx.print(chalk.dim('no restorable writes this session'));
102
+ return;
103
+ }
104
+ const CANCEL = '__cspeach_rewind_cancel__';
105
+ const choices = candidates.map((c, i) => ({
106
+ name: `${String(i + 1).padStart(2)}. ${rewindRowLabel(c)}`
107
+ + (packageUnknown(c) ? ' (package unknown — restore may fail)' : ''),
108
+ value: String(i),
109
+ disabled: c.restorable ? false : 'no restore point',
110
+ }));
111
+ choices.push({ name: 'Cancel', value: CANCEL, disabled: false });
112
+ const pick = await withInquirer(() => select({
113
+ message: 'Rewind which write?',
114
+ theme: inquirerTheme,
115
+ choices,
116
+ }));
117
+ if (typeof pick !== 'string' || pick === CANCEL)
118
+ return;
119
+ const chosen = candidates[Number(pick)];
120
+ if (!chosen || !chosen.restorable)
121
+ return;
122
+ const proceed = await withInquirer(() => confirm({
123
+ message: `Restore ${chosen.object} to snapshot ${hhmm(chosen.snapshotAt)}? This runs the write gates.`,
124
+ default: false,
125
+ }));
126
+ if (!proceed)
127
+ return;
128
+ await executeRestore(chosen, {
129
+ adt: ctx.adt,
130
+ cfg: ctx.cfg,
131
+ alias: ctx.alias,
132
+ toolCtx: ctx.toolCtx,
133
+ suppliedTransport: ctx.suppliedTransport,
134
+ requestApproval: (change, risk, transport) => renderPerChangeApproval(change, risk, transport),
135
+ log: (line) => ctx.print(chalk.dim(line)),
136
+ }, ctx.print);
137
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Pure row/label formatters shared by the Ink RewindPanel and the classic
3
+ * inquirer list (rewind/cli.ts). Kept ink-free so importing it from the
4
+ * classic path does not drag React/Ink into a non-TTY process.
5
+ */
6
+ /** UTC HH:MM from an ISO timestamp — deterministic (write & snapshot times are
7
+ * both UTC 'Z', so relative sense is preserved without a TZ dependency). */
8
+ export function hhmm(iso) {
9
+ if (!iso)
10
+ return '??:??';
11
+ const m = /T(\d{2}):(\d{2})/.exec(iso);
12
+ return m ? `${m[1]}:${m[2]}` : '??:??';
13
+ }
14
+ /** Row label without glyph / selection styling. Restorable rows carry the
15
+ * snapshot time; non-restorable rows carry the reason. */
16
+ export function rewindRowLabel(c) {
17
+ const tool = c.writeTool.replace(/^sap_/, '');
18
+ const base = `${c.object} (${c.objType}) · ${tool} · ${hhmm(c.writtenAt)}`;
19
+ if (c.restorable)
20
+ return `${base} · snapshot ${hhmm(c.snapshotAt)}`;
21
+ return `${base} — ${c.reason ?? 'no restore point'}`;
22
+ }
23
+ /** True when a restorable delete has no recreate package to fall back on —
24
+ * the restore may fail on the real system's empty-package rejection. */
25
+ export function packageUnknown(c) {
26
+ return c.restorable && c.writeTool === 'sap_delete_object' && !c.package;
27
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Restore executor — UX Wave 3, Task 2. The gated, harness-driven write that
3
+ * makes "undo for your SAP system" real.
4
+ *
5
+ * A restore IS a write to a live SAP system, so it runs the FULL safety ladder
6
+ * — the harness-side twin of the `sap_set_source` tool's gate pipeline
7
+ * (cspeach-cli/src/tools/sap-write.ts). Same AdtClient calls, same order:
8
+ *
9
+ * getEffectiveWriteMode clamp (advisory-only ⇒ blocked, NO prompt)
10
+ * → snapshots.read (restore source)
11
+ * → adt.getSource (current source; 404 tolerated ⇒ delete-restore)
12
+ * → deps.requestApproval (synthetic Change + riskFloor) ← the gate
13
+ * → snapshots.take (Rule 7: snapshot CURRENT before overwrite)
14
+ * → [adt.createObject when current===null] ← delete-restore create-first
15
+ * → adt.setSource (write the snapshot content)
16
+ * → verifySyntax (Rule 10: syntax errors ⇒ STOP, object left inactive)
17
+ * → adt.activate + verifyActive (Rule 10: confirm active)
18
+ *
19
+ * Pure orchestration over INJECTED deps — no model turn, no approval mechanics
20
+ * of its own. It never imports the approvalEmitter or inquirer: the caller
21
+ * (Task 3) injects `requestApproval`, which bridges to the Ink emitter or the
22
+ * classic prompt. That keeps this module fully unit-testable and guarantees a
23
+ * declined approval provably writes nothing.
24
+ */
25
+ import { snapshots, SapError } from '@cspeach/sap-client';
26
+ import { getEffectiveWriteMode } from '../repl/mode-cycle.js';
27
+ import { effectiveRisk } from '../approvals/risk-floor.js';
28
+ import { verifySyntax, verifyActive } from '../tools/verify.js';
29
+ import { renderDiff } from '../repl/diff-display.js';
30
+ /** Compact single-line failure text for receipts / error envelopes. */
31
+ function shortError(err) {
32
+ const msg = err instanceof Error ? err.message : String(err);
33
+ return msg.replace(/\s+/g, ' ').trim();
34
+ }
35
+ /**
36
+ * Restore an object to the source captured in a prior snapshot, through the
37
+ * full write-safety ladder. Returns `{ ok, steps, error? }`; on any stop
38
+ * before the write, nothing has been written to SAP.
39
+ */
40
+ export async function restoreSnapshot(c, deps) {
41
+ const steps = [];
42
+ const record = (line) => {
43
+ steps.push(line);
44
+ deps.log(line);
45
+ };
46
+ // ── Step 1: Effective-mode clamp FIRST — before any prompt or read ────────
47
+ // A prd ceiling (or an explicit advisory-only config/session mode) forbids
48
+ // writes entirely. Restore is a write, so it is blocked by policy with no
49
+ // approval prompt shown. Mirrors approval.ts's advisory short-circuit.
50
+ if (getEffectiveWriteMode(deps.cfgWriteMode, deps.role) === 'advisory-only') {
51
+ return { ok: false, steps, error: 'write mode is advisory-only — restore blocked (policy)' };
52
+ }
53
+ // Defensive guard: a non-restorable candidate has no restore point. Task 3
54
+ // only offers restorable rows, but never trust the caller with a live write.
55
+ if (c.snapshotId === null) {
56
+ return { ok: false, steps, error: 'no snapshot to restore from' };
57
+ }
58
+ // ── Step 2: Read the snapshot source (the content we will restore) ────────
59
+ let snapshotSource;
60
+ try {
61
+ snapshotSource = await snapshots.read(c.objType, c.object, c.snapshotId);
62
+ }
63
+ catch (err) {
64
+ return { ok: false, steps, error: `could not read snapshot: ${shortError(err)}` };
65
+ }
66
+ // ── Step 3: Read current source — tolerate a genuine 404 (delete-restore) ─
67
+ // A 404 means the object was deleted this session; current = null and we
68
+ // will recreate it. Any OTHER read failure is NOT proof of absence — stop
69
+ // rather than blindly treat it as a create.
70
+ let currentSource;
71
+ try {
72
+ currentSource = await deps.adt.getSource(c.objType, c.object);
73
+ }
74
+ catch (err) {
75
+ if (err instanceof SapError && err.httpStatus === 404) {
76
+ currentSource = null;
77
+ }
78
+ else {
79
+ return { ok: false, steps, error: `could not read current source: ${shortError(err)}` };
80
+ }
81
+ }
82
+ // ── Step 4: Approval — the gate. Synthetic Change + riskFloor path ────────
83
+ // op is 'create' when the object is gone (delete-restore), else 'modify'.
84
+ // The diff is built with the SAME builder the update-method preview uses
85
+ // (repl/diff-display.renderDiff → `diff` createPatch), current → snapshot.
86
+ const op = currentSource === null ? 'create' : 'modify';
87
+ const change = {
88
+ op,
89
+ object: c.object,
90
+ type: c.objType,
91
+ diff: renderDiff(currentSource ?? '', snapshotSource, {
92
+ label: `${c.objType}/${c.object}`,
93
+ syntaxHighlight: false,
94
+ }),
95
+ // package is unknown at rewind time (the snapshot store carries no package,
96
+ // and a deleted object cannot be re-read for it) — omit per the Change
97
+ // contract (the approval box drops the segment rather than showing a stub).
98
+ };
99
+ // Risk via the same effectiveRisk path approval.ts uses. Rider (b): the
100
+ // alias's real auto_approve policy is threaded in (default 'never', exactly
101
+ // as approval.ts does with sapCfg?.auto_approve ?? 'never') so the modal's
102
+ // risk label is true for this system. sapAlias is unused by effectiveRisk.
103
+ const riskCtx = { sapAlias: '', autoApprovePolicy: deps.autoApprovePolicy ?? 'never' };
104
+ const risk = effectiveRisk('medium', [change], riskCtx);
105
+ const outcome = await deps.requestApproval(change, risk, deps.transport);
106
+ if (!outcome.approved) {
107
+ // Declined ⇒ provably nothing written: we return before any take/write.
108
+ return { ok: false, steps, error: 'declined' };
109
+ }
110
+ // ── Step 5: Rule 7 — snapshot CURRENT before we overwrite it ──────────────
111
+ // Skip only when the object is gone (nothing to snapshot). The reason tag
112
+ // 'before_rewind' distinguishes these from ordinary 'before_write' snaps.
113
+ if (currentSource !== null) {
114
+ try {
115
+ await snapshots.take(c.objType, c.object, currentSource, 'before_rewind');
116
+ }
117
+ catch (err) {
118
+ return { ok: false, steps, error: `snapshot before restore failed: ${shortError(err)}` };
119
+ }
120
+ record('① snapshot ✓');
121
+ }
122
+ else {
123
+ record('① snapshot — skipped (object was deleted this session)');
124
+ }
125
+ // ── Step 6: Write the snapshot source — create-first for delete-restore ───
126
+ // sap_set_source's write path is a single adt.setSource call (lock→PUT→unlock
127
+ // handled inside the client). For a deleted object we must recreate the shell
128
+ // first, then write the source. Rider (c): the package is harvested from this
129
+ // session's own sap_create_object (candidate.package); when absent, createObject
130
+ // gets '' and a real system rejects the empty package — the pipeline stops here
131
+ // with an honest error (the panel warns before the user ever gets this far).
132
+ if (currentSource === null) {
133
+ let created;
134
+ try {
135
+ created = await deps.adt.createObject(c.objType, c.object, c.package ?? '', 'Restored via CSPeach rewind', deps.transport);
136
+ }
137
+ catch (err) {
138
+ record('② written ✗');
139
+ return { ok: false, steps, error: `recreate failed: ${shortError(err)}` };
140
+ }
141
+ if (created.status !== 'created') {
142
+ record('② written ✗');
143
+ return { ok: false, steps, error: `recreate failed: ${created.error ?? `HTTP ${created.httpStatus}`}` };
144
+ }
145
+ }
146
+ let writeResult;
147
+ try {
148
+ writeResult = await deps.adt.setSource(c.objType, c.object, snapshotSource, deps.transport);
149
+ }
150
+ catch (err) {
151
+ record('② written ✗');
152
+ return { ok: false, steps, error: `write failed: ${shortError(err)}` };
153
+ }
154
+ // setSource RETURNS (does not throw) on HTTP 4xx — check the return value,
155
+ // exactly as sap_set_source does.
156
+ if (writeResult.error || (writeResult.writeStatus !== undefined && writeResult.writeStatus >= 400)) {
157
+ const detail = writeResult.error ?? `HTTP ${writeResult.writeStatus}`;
158
+ record('② written ✗');
159
+ return { ok: false, steps, error: `write failed: ${detail}` };
160
+ }
161
+ record('② written ✓');
162
+ // ── Step 7: Syntax check (Rule 10) — errors ⇒ STOP, do NOT activate ───────
163
+ // I2 (review): the raw post-write awaits used to escape — a network drop here
164
+ // became an unhandled rejection → process exit AFTER '② written ✓' with no
165
+ // receipt, so the user could not tell whether the restore landed. Every
166
+ // post-write await is now caught and reported with an honest step line: the
167
+ // WRITE HAPPENED; only the verification/activation call failed to run.
168
+ let syntax;
169
+ try {
170
+ syntax = await verifySyntax(deps.adt, c.object, c.objType);
171
+ }
172
+ catch (err) {
173
+ record(`③ syntax — check failed to run: ${shortError(err)}`);
174
+ return {
175
+ ok: false,
176
+ steps,
177
+ error: `snapshot source was written, but the syntax check failed to run: ${shortError(err)} — `
178
+ + `${c.objType}/${c.object} is NOT activated; verify it manually.`,
179
+ };
180
+ }
181
+ if (!syntax.ok) {
182
+ record('③ syntax ✗');
183
+ return {
184
+ ok: false,
185
+ steps,
186
+ error: `snapshot source restored but has syntax errors — ${c.objType}/${c.object} is now INACTIVE. `
187
+ + `Fix or re-restore before use. ${syntax.errors.join('; ')}`,
188
+ };
189
+ }
190
+ record('③ syntax ✓');
191
+ // ── Step 8: Activate + verify (Rule 10) ───────────────────────────────────
192
+ let activation;
193
+ try {
194
+ activation = await deps.adt.activate(c.objType, c.object);
195
+ }
196
+ catch (err) {
197
+ record(`④ activate — call failed: ${shortError(err)}`);
198
+ return {
199
+ ok: false,
200
+ steps,
201
+ error: `snapshot source was written and syntax-checked, but the activation call failed: ${shortError(err)} — `
202
+ + `${c.objType}/${c.object} is likely INACTIVE; activate it manually.`,
203
+ };
204
+ }
205
+ if (!activation.success) {
206
+ record('④ activated ✗');
207
+ const detail = activation.messages.map((m) => m.shortText).filter(Boolean).join('; ');
208
+ return {
209
+ ok: false,
210
+ steps,
211
+ error: `restored but activation failed — ${c.objType}/${c.object} is INACTIVE.${detail ? ' ' + detail : ''}`,
212
+ };
213
+ }
214
+ record('④ activated ✓');
215
+ // verifyActive catches its own inactiveObjects() failure (returns
216
+ // verifyError) — but wrap anyway so no future refactor can leak a throw
217
+ // past the receipt.
218
+ let active;
219
+ try {
220
+ active = await verifyActive(deps.adt, c.object, c.objType);
221
+ }
222
+ catch (err) {
223
+ active = { active: false, verifyError: shortError(err) };
224
+ }
225
+ if (active.verifyError) {
226
+ // Post-check itself failed — never claim success we could not confirm.
227
+ record('⑤ verify ✗');
228
+ return {
229
+ ok: false,
230
+ steps,
231
+ error: `restored and activation reported success, but could not verify active state: ${active.verifyError}`,
232
+ };
233
+ }
234
+ if (!active.active) {
235
+ // Silent activation failure — object still in the inactive list.
236
+ record('⑤ verify ✗');
237
+ return {
238
+ ok: false,
239
+ steps,
240
+ error: `activation reported success but ${c.objType}/${c.object} is still inactive`,
241
+ };
242
+ }
243
+ record('⑤ verified ✓');
244
+ return { ok: true, steps };
245
+ }