@cspeach/cli 1.0.0 → 1.1.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/dist/agent/loop.js +22 -9
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +189 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-resume.js +588 -33
- package/dist/config/loader.js +128 -4
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +25 -3
- package/dist/projects/extract-cca.js +3 -1
- package/dist/projects/extract-modernize.js +3 -1
- package/dist/projects/extract-plan.js +60 -6
- package/dist/projects/extract-test-coverage.js +3 -1
- package/dist/projects/extract-upgrade.js +3 -1
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +1 -1
- package/dist/projects/plan-run.js +137 -13
- package/dist/projects/plan-schema.js +73 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +26 -15
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +37 -0
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +5 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/post-turn-status.js +24 -4
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl.js +954 -83
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +9 -3
- package/dist/skills/bundled-skills.js +59 -66
- package/dist/tools/approval.js +115 -7
- package/dist/tools/ask-question.js +304 -3
- package/dist/tools/extend-model/anchored-insert.js +604 -0
- package/dist/tools/extend-model/tool.js +162 -10
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +29 -3
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +328 -3
- package/dist/tools/local-build.js +11 -1
- package/dist/tools/sap-read.js +79 -11
- package/dist/tools/sap-write.js +24 -4
- package/dist/tools/snapshot.js +27 -1
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/todo.js +144 -0
- package/dist/ui/app.js +372 -19
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +109 -30
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +18 -2
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +72 -8
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +17 -7
- 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
|
+
}
|