@cspeach/cli 1.1.40 → 1.1.42
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/.cspeach/project.json +4 -0
- package/dist/agent/loop.js +3 -1
- package/dist/agent/tool-dispatch.js +9 -3
- package/dist/approvals/op-labels.js +18 -1
- package/dist/approvals/render.js +27 -7
- package/dist/approvals/risk-floor.js +16 -0
- package/dist/renderer/tool-labels.js +4 -1
- package/dist/sap/profile-features.js +5 -0
- package/dist/sap/profile-gate.js +39 -1
- package/dist/sap/system-info.js +15 -10
- package/dist/sap/system-profile.js +29 -4
- package/dist/session/resume.js +3 -0
- package/dist/tools/approval.js +20 -1
- package/dist/tools/sap-read.js +79 -1
- package/dist/tools/sap-write.js +692 -1234
- package/dist/tools/verify.js +63 -14
- package/dist/tools/write-pipeline.js +442 -0
- package/dist/ui/approval-modal.js +16 -4
- package/package.json +2 -2
package/dist/agent/loop.js
CHANGED
|
@@ -837,7 +837,9 @@ ${imageExpansion.error}`));
|
|
|
837
837
|
sapSystemInfo = await getSapSystemInfo(params.ctx.adt, params.ctx.sapAlias);
|
|
838
838
|
// ADT gap program, Part 1 — the system profile rides the same lazy probe.
|
|
839
839
|
// Null (kill switch, no discovery and no CVERS) = today's behaviour.
|
|
840
|
-
|
|
840
|
+
// Part 2a D14: the turn's signal cancels the build's reads on Esc (null
|
|
841
|
+
// for this turn, nothing cached).
|
|
842
|
+
params.ctx.systemProfile = await getSystemProfile(params.ctx.adt, params.ctx.sapAlias, sapSystemInfo, { signal: params.signal });
|
|
841
843
|
sapSystemBlock = renderSapSystemBlock(sapSystemInfo);
|
|
842
844
|
sapProfileBlock = renderSapProfileBlock(params.ctx.systemProfile);
|
|
843
845
|
}
|
|
@@ -9,7 +9,8 @@ import { shouldGateRule8, recordWriteOp, renderPlanSummary, getWriteOpsThisTurn,
|
|
|
9
9
|
import { isCredentialHandoverApproved, markCredentialHandoverApproved, } from '../repl/credential-handover-gate.js';
|
|
10
10
|
import { READER_TOOLS } from '../tools/subagent/reader-prompt.js';
|
|
11
11
|
import { riskOf } from './risk-gate.js';
|
|
12
|
-
import { NOT_ON_THIS_RELEASE,
|
|
12
|
+
import { NOT_ON_THIS_RELEASE, gateWithVerify } from '../sap/profile-gate.js';
|
|
13
|
+
import { INTERRUPTED_TOOL_RESULT } from './repair-partial.js';
|
|
13
14
|
const READER_TOOL_SET = new Set(READER_TOOLS);
|
|
14
15
|
export async function dispatchTool(name, args, ctx) {
|
|
15
16
|
const tool = getTool(name);
|
|
@@ -39,14 +40,19 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
39
40
|
}
|
|
40
41
|
// ADT gap program, Part 1 (D4) — a definite "not on this release" is
|
|
41
42
|
// answered here, before any SAP call, as a normal tool error. Unknown or
|
|
42
|
-
// no profile: today's path, untouched.
|
|
43
|
-
|
|
43
|
+
// no profile: today's path, untouched. Part 2a D11 — a `no` is verified
|
|
44
|
+
// against a fresh profile once per alias before it is believed.
|
|
45
|
+
const gate = await gateWithVerify(tool, args, ctx);
|
|
44
46
|
if (gate) {
|
|
45
47
|
return {
|
|
46
48
|
content: JSON.stringify({ error: NOT_ON_THIS_RELEASE, feature: gate.feature, message: gate.message }),
|
|
47
49
|
is_error: true,
|
|
48
50
|
};
|
|
49
51
|
}
|
|
52
|
+
// Final review I-3 — the D11 re-check can take seconds of SAP reads; an Esc
|
|
53
|
+
// in that window must stop the call here, not let the handler run "as unknown".
|
|
54
|
+
if (ctx.signal?.aborted)
|
|
55
|
+
return { content: INTERRUPTED_TOOL_RESULT, is_error: true };
|
|
50
56
|
// L1-6 R1-6 (owner decision 2026-09-21) — a call that hands the stored SAP
|
|
51
57
|
// login to a local child process ALWAYS asks a human, once per session per
|
|
52
58
|
// alias, even as the first mutating call of a turn (where Rule 8 below stays
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { typeCode } from './risk-floor.js';
|
|
2
|
+
import { sanitizeControlChars } from '../renderer/sanitize.js';
|
|
1
3
|
/**
|
|
2
4
|
* Human-readable operation + object labels for the approval surfaces.
|
|
3
5
|
*
|
|
@@ -36,7 +38,8 @@ const TYPE_WORDS = {
|
|
|
36
38
|
TTYP: 'table type',
|
|
37
39
|
MSAG: 'message class',
|
|
38
40
|
DEVC: 'package',
|
|
39
|
-
ENHO: '
|
|
41
|
+
ENHO: 'BAdI implementation',
|
|
42
|
+
ENHS: 'BAdI enhancement spot',
|
|
40
43
|
XSLT: 'transformation',
|
|
41
44
|
SHLP: 'search help',
|
|
42
45
|
};
|
|
@@ -49,6 +52,11 @@ export function humanType(type) {
|
|
|
49
52
|
const key = type.trim().toUpperCase();
|
|
50
53
|
if (TYPE_WORDS[key])
|
|
51
54
|
return TYPE_WORDS[key];
|
|
55
|
+
// Part 2b D7 — the BAdI types read the same with a subtype ('ENHO/XHB').
|
|
56
|
+
// Every other type keeps today's label.
|
|
57
|
+
const code = typeCode(type);
|
|
58
|
+
if (code === 'ENHO' || code === 'ENHS')
|
|
59
|
+
return TYPE_WORDS[code];
|
|
52
60
|
// Already a word (has a lowercase letter or a space)? use it verbatim.
|
|
53
61
|
if (/[a-z ]/.test(type))
|
|
54
62
|
return type;
|
|
@@ -60,6 +68,15 @@ export function verbLine(change) {
|
|
|
60
68
|
.filter(Boolean)
|
|
61
69
|
.join(' ');
|
|
62
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Part 2b D7 — "Affects: <process>" for a change that names the standard SAP
|
|
73
|
+
* process it runs in (a BAdI implementation); null when it names none. The
|
|
74
|
+
* text comes from the model: control characters are removed.
|
|
75
|
+
*/
|
|
76
|
+
export function affectsLine(change) {
|
|
77
|
+
const t = sanitizeControlChars(String(change.affects ?? '')).trim();
|
|
78
|
+
return t ? `Affects: ${t}` : null;
|
|
79
|
+
}
|
|
63
80
|
/**
|
|
64
81
|
* The transport to DISPLAY on the approval surface for one change — mirrors
|
|
65
82
|
* how the write tools actually resolve it (traced in sap-write.ts, 2026-07-05
|
package/dist/approvals/render.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { select, input } from '@inquirer/prompts';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { riskReasonText, shownRiskReasons } from './risk-floor.js';
|
|
4
|
-
import { verbLine, viewAffordance, toSourceLines } from './op-labels.js';
|
|
4
|
+
import { verbLine, viewAffordance, toSourceLines, affectsLine } from './op-labels.js';
|
|
5
5
|
import { shouldUseInk } from '../renderer/tty.js';
|
|
6
6
|
import { approvalEmitter } from '../ui/approval-emitter.js';
|
|
7
7
|
import { withInquirer } from '../repl/inquirer-guard.js';
|
|
@@ -114,15 +114,29 @@ export async function renderPlanGate(summary, changes, transport, effectiveRisk,
|
|
|
114
114
|
if (shownReasons.length > 0) {
|
|
115
115
|
facts.push(`Risk raised to ${effectiveRisk.level}: ${shownReasons.map(riskReasonText).join('; ')}`);
|
|
116
116
|
}
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
// Part 2b D7 — a step that names the process it runs in (a BAdI
|
|
118
|
+
// implementation) carries its "Affects:" row right under it. The card's
|
|
119
|
+
// step budget counts rows, and a step is never shown without its row.
|
|
120
|
+
// No affects anywhere: one row per step, exactly as before.
|
|
121
|
+
const steps = visibleChanges.map((c, i) => {
|
|
122
|
+
const affects = affectsLine(c);
|
|
123
|
+
return [`${i + 1}. ${verbLine(c)}`, ...(affects ? [` ${affects}`] : [])];
|
|
124
|
+
});
|
|
125
|
+
const totalRows = steps.reduce((n, s) => n + s.length, 0);
|
|
126
|
+
if (totalRows > MAX_STEPS) {
|
|
119
127
|
// Object names are model-supplied: neutralise control characters
|
|
120
128
|
// before they reach scrollback (the card sanitises its own rows).
|
|
121
|
-
emitTrace(sanitizeControlChars(['Plan', ...steps.map((s) => ` ${s}`)].join('\n')));
|
|
122
|
-
|
|
129
|
+
emitTrace(sanitizeControlChars(['Plan', ...steps.flat().map((s) => ` ${s}`)].join('\n')));
|
|
130
|
+
let shown = 0;
|
|
131
|
+
let rows = 0;
|
|
132
|
+
while (shown < steps.length && (shown === 0 || rows + steps[shown].length <= MAX_STEPS)) {
|
|
133
|
+
rows += steps[shown].length;
|
|
134
|
+
shown += 1;
|
|
135
|
+
}
|
|
136
|
+
facts.push(...steps.slice(0, shown).flat(), `${getGlyphs().more} ${steps.length - shown} more steps, listed above`);
|
|
123
137
|
}
|
|
124
138
|
else {
|
|
125
|
-
facts.push(...steps);
|
|
139
|
+
facts.push(...steps.flat());
|
|
126
140
|
}
|
|
127
141
|
const res = await askConfirm({
|
|
128
142
|
id: 'plan-gate',
|
|
@@ -162,6 +176,10 @@ async function classicPlanGate(summary, visibleChanges, transport, effectiveRisk
|
|
|
162
176
|
}
|
|
163
177
|
visibleChanges.forEach((c, i) => {
|
|
164
178
|
console.log(` ${chalk.dim(`${i + 1}.`)} ${verbLine(c)}`);
|
|
179
|
+
// Part 2b D7 — the process a BAdI implementation runs in, under its line.
|
|
180
|
+
const affects = affectsLine(c);
|
|
181
|
+
if (affects)
|
|
182
|
+
console.log(` ${chalk.dim(affects)}`);
|
|
165
183
|
});
|
|
166
184
|
console.log('');
|
|
167
185
|
const choice = await withInquirer(() => select({
|
|
@@ -193,8 +211,10 @@ export async function renderPerChangeApproval(change, effectiveRisk, transport)
|
|
|
193
211
|
if (aff)
|
|
194
212
|
choices.push({ value: 'diff', name: aff.label });
|
|
195
213
|
choices.push({ value: 'skip', name: 'Skip (tell model why)' }, { value: 'cancel', name: 'Cancel turn' });
|
|
214
|
+
// Part 2b D7 — the process a BAdI implementation runs in, under the verb line.
|
|
215
|
+
const affects = affectsLine(change);
|
|
196
216
|
const choice = await withInquirer(() => select({
|
|
197
|
-
message: `${objLabel} — apply
|
|
217
|
+
message: `${objLabel} — apply?${affects ? `\n ${chalk.dim(affects)}` : ''}`,
|
|
198
218
|
default: 'apply',
|
|
199
219
|
theme: inquirerTheme,
|
|
200
220
|
choices,
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Part 2b D7 — the type code of a change or id: trimmed, upper case, without a
|
|
3
|
+
* `/subtype` ('enho/xhb' → 'ENHO'). The ENHO risk floor, the affects_required
|
|
4
|
+
* refusal and the write tool's type binding all use it, so they always agree.
|
|
5
|
+
*/
|
|
6
|
+
export function typeCode(type) {
|
|
7
|
+
return String(type ?? '').trim().toUpperCase().split('/')[0].trim();
|
|
8
|
+
}
|
|
1
9
|
export function effectiveRisk(declared, changes, ctx) {
|
|
2
10
|
let floor = declared;
|
|
3
11
|
const raisedBy = [];
|
|
@@ -28,6 +36,13 @@ export function effectiveRisk(declared, changes, ctx) {
|
|
|
28
36
|
raisedBy.push('syntax_errors');
|
|
29
37
|
}
|
|
30
38
|
}
|
|
39
|
+
// Part 2b D7 — a BAdI implementation runs inside a standard SAP process:
|
|
40
|
+
// high (never auto-approved), and the reason is always shown.
|
|
41
|
+
if (changes.some((c) => typeCode(c.type) === 'ENHO')) {
|
|
42
|
+
raise('high', 'standard_process');
|
|
43
|
+
if (!raisedBy.includes('standard_process'))
|
|
44
|
+
raisedBy.push('standard_process');
|
|
45
|
+
}
|
|
31
46
|
if (changes.some((c) => c.op === 'delete'))
|
|
32
47
|
raise('medium', 'delete_op');
|
|
33
48
|
// Round 4 item 8a — these were both labelled 'shared_transport', so a plan
|
|
@@ -58,6 +73,7 @@ const RISK_REASON_TEXT = {
|
|
|
58
73
|
escalated_in_session: 'more than 5 changes in one plan',
|
|
59
74
|
prod_namespace: 'the object is in a production namespace',
|
|
60
75
|
syntax_errors: 'the last syntax check of this object found errors',
|
|
76
|
+
standard_process: 'this code runs inside a standard SAP process (a BAdI implementation)',
|
|
61
77
|
};
|
|
62
78
|
/** Round 8 item 4 — every code the floor can raise (each has plain words above). */
|
|
63
79
|
export const RISK_REASON_CODES = Object.keys(RISK_REASON_TEXT);
|
|
@@ -167,6 +167,9 @@ const RULES = {
|
|
|
167
167
|
rules_propose: () => ({ verb: 'Check a proposed decision rule', object: 'nothing is saved' }),
|
|
168
168
|
team_estate: (a) => ({ verb: 'Read estate inventory', object: str(a['packages']) ?? 'all packages' }),
|
|
169
169
|
sap_badi_list: (a) => ({ verb: 'Searched BAdIs', object: shortRight(`"${str(a['query']) ?? ''}"`) }),
|
|
170
|
+
sap_badi_read: (a) => ({ verb: 'Read BAdI', object: shortRight([str(a['kind']), str(a['name'])].filter(Boolean).join(' ')) }),
|
|
171
|
+
sap_enhancement_options: (a) => ({ verb: 'Read enhancement options', object: shortRight([str(a['type']), str(a['name'])].filter(Boolean).join(' ')) }),
|
|
172
|
+
sap_create_badi_implementation: sapObject('Created BAdI implementation'),
|
|
170
173
|
sap_snapshot_list: (a) => ({ verb: 'Listed snapshots', object: shortRight(str(a['name']) ?? '') }),
|
|
171
174
|
sap_snapshot_read: sapObject('Read snapshot'),
|
|
172
175
|
sap_snapshot_cleanup: () => ({ verb: 'Cleaned snapshots', object: '' }),
|
|
@@ -265,7 +268,7 @@ const PRESENT = {
|
|
|
265
268
|
'Read snapshot': 'Reading snapshot of', 'Cleaned snapshots': 'Cleaning snapshots',
|
|
266
269
|
'Created transport': 'Creating transport', 'Transport for': 'Finding transport for',
|
|
267
270
|
'Listed transports': 'Listing transports', Released: 'Releasing', 'Created domain': 'Creating domain',
|
|
268
|
-
'Created data element': 'Creating data element', Locked: 'Locking', Unlocked: 'Unlocking',
|
|
271
|
+
'Created data element': 'Creating data element', 'Read BAdI': 'Reading BAdI', 'Read enhancement options': 'Reading enhancement options of', 'Created BAdI implementation': 'Creating BAdI implementation', Locked: 'Locking', Unlocked: 'Unlocking',
|
|
269
272
|
Published: 'Publishing', Unpublished: 'Unpublishing', Messages: 'Maintaining messages in',
|
|
270
273
|
'Number range': 'Maintaining number range', Question: 'Asking', 'Updated todos': 'Updating todos',
|
|
271
274
|
'Ran skill': 'Running skill', 'Read document': 'Reading document', Fetched: 'Fetching',
|
|
@@ -26,6 +26,10 @@ export const FEATURES = Object.freeze({
|
|
|
26
26
|
'datapreview.freestyle': { label: 'SQL data preview', collection: `${A}/datapreview/freestyle`, provenOnOurBox: true },
|
|
27
27
|
'msag': { label: 'Message classes', collection: `${A}/messageclass`, provenOnOurBox: true },
|
|
28
28
|
'nrob': { label: 'Number range objects', collection: `${A}/numberranges/objects`, provenOnOurBox: true },
|
|
29
|
+
// Part 2b: no `since`. The gap table's "enhancement editor 1809, BAdI editor
|
|
30
|
+
// 2020" has no cited source (Part 1 I1 rule): listed = yes, else unknown.
|
|
31
|
+
'enh.badiImplementations': { label: 'BAdI implementations', collection: `${A}/enhancements/enhoxhb`, provenOnOurBox: true },
|
|
32
|
+
'enh.badiSpots': { label: 'BAdI enhancement spots', collection: `${A}/enhancements/enhsxsb`, provenOnOurBox: true },
|
|
29
33
|
});
|
|
30
34
|
export const FEATURE_IDS = Object.freeze(Object.keys(FEATURES));
|
|
31
35
|
export function isFeatureId(s) {
|
|
@@ -37,6 +41,7 @@ export const OBJECT_TYPE_FEATURE = Object.freeze({
|
|
|
37
41
|
DDLS: 'cds.ddls', CDS: 'cds.ddls', DDLX: 'cds.ddlx', DCLS: 'cds.dcls',
|
|
38
42
|
TABL: 'ddic.tables', STRU: 'ddic.structures', DOMA: 'ddic.domains', DTEL: 'ddic.dataelements',
|
|
39
43
|
MSAG: 'msag', NROB: 'nrob',
|
|
44
|
+
ENHO: 'enh.badiImplementations', ENHS: 'enh.badiSpots',
|
|
40
45
|
});
|
|
41
46
|
/** The RAP/CDS subset: the only types a READ is ever refused for (R6). */
|
|
42
47
|
export const READ_GATED_TYPES = new Set(['BDEF', 'SRVD', 'SRVB', 'DDLS', 'CDS', 'DDLX', 'DCLS']);
|
package/dist/sap/profile-gate.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { FEATURES, OBJECT_TYPE_FEATURE, READ_GATED_TYPES } from './profile-features.js';
|
|
2
|
-
import { supports, systemLabel } from './system-profile.js';
|
|
2
|
+
import { getSystemProfile, supports, systemLabel } from './system-profile.js';
|
|
3
|
+
import { getSapSystemInfo } from './system-info.js';
|
|
3
4
|
export const NOT_ON_THIS_RELEASE = 'not_on_this_release';
|
|
4
5
|
/** For tools whose `type` argument names an object type (url-mapper token). */
|
|
5
6
|
export function byObjectType(args) {
|
|
@@ -65,3 +66,40 @@ export function pinHiddenTools(session, alias, tools, profile) {
|
|
|
65
66
|
session.profile_hidden_tools = { alias, names };
|
|
66
67
|
return names;
|
|
67
68
|
}
|
|
69
|
+
// ── Part 2a — the three profile gates (owner decisions D11–D13) ────────────
|
|
70
|
+
const verifiedThisProcess = new Set(); // alias → a fresh rebuild returned a profile after a `no`
|
|
71
|
+
export function _resetVerifiedForTests() { verifiedThisProcess.clear(); }
|
|
72
|
+
/**
|
|
73
|
+
* D11 — a definite `no` is checked against a fresh profile once per alias per
|
|
74
|
+
* process before it is believed: the cache is up to 30 days old and the system
|
|
75
|
+
* may have been upgraded. The pinned tool list is not touched (R7).
|
|
76
|
+
*/
|
|
77
|
+
export async function gateWithVerify(tool, args, ctx) {
|
|
78
|
+
const first = profileGate(tool, args, ctx.systemProfile ?? null);
|
|
79
|
+
if (!first || verifiedThisProcess.has(ctx.sapAlias))
|
|
80
|
+
return first;
|
|
81
|
+
const info = await getSapSystemInfo(ctx.adt, ctx.sapAlias, { fresh: true });
|
|
82
|
+
ctx.systemProfile = await getSystemProfile(ctx.adt, ctx.sapAlias, info, { fresh: true, signal: ctx.signal });
|
|
83
|
+
// Only a rebuild that returned a profile counts as verified. An aborted or
|
|
84
|
+
// failed one leaves the alias unmarked (this call runs as unknown), so the
|
|
85
|
+
// next refusal of a stale cached `no` re-checks: one attempt per refusal.
|
|
86
|
+
if (ctx.systemProfile)
|
|
87
|
+
verifiedThisProcess.add(ctx.sapAlias);
|
|
88
|
+
return profileGate(tool, args, ctx.systemProfile ?? null);
|
|
89
|
+
}
|
|
90
|
+
/** D12 — the changes of a request_approval call whose type's feature is a definite no (after verify). */
|
|
91
|
+
export async function approvalRefusedByProfile(changes, ctx) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const c of changes) {
|
|
94
|
+
if (!['create', 'modify', 'delete'].includes(c.op))
|
|
95
|
+
continue;
|
|
96
|
+
const g = await gateWithVerify({ name: 'request_approval', requires: byObjectType }, { type: c.type }, ctx);
|
|
97
|
+
if (g)
|
|
98
|
+
out.push({ object: c.object, type: String(c.type ?? ''), message: g.message });
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
/** D13 — a resumed session pins its hidden tools again on its first turn. */
|
|
103
|
+
export function clearProfilePin(session) {
|
|
104
|
+
delete session.profile_hidden_tools;
|
|
105
|
+
}
|
package/dist/sap/system-info.js
CHANGED
|
@@ -30,16 +30,21 @@ const memCache = new Map();
|
|
|
30
30
|
* Top-level entry point. Returns cached info when available, queries
|
|
31
31
|
* CVERS once otherwise, persists the result, returns null on any error.
|
|
32
32
|
*/
|
|
33
|
-
export async function getSapSystemInfo(adt, alias) {
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
export async function getSapSystemInfo(adt, alias, opts = {}) {
|
|
34
|
+
// Part 2a D11 — `fresh` skips layers 1 and 2 (a definite "no" is checked
|
|
35
|
+
// against the live system before it is believed); the live path below is
|
|
36
|
+
// unchanged and refreshes both caches on success.
|
|
37
|
+
if (!opts.fresh) {
|
|
38
|
+
// Layer 1 — process memory
|
|
39
|
+
const cached = memCache.get(alias);
|
|
40
|
+
if (cached)
|
|
41
|
+
return cached;
|
|
42
|
+
// Layer 2 — disk
|
|
43
|
+
const fromDisk = readDiskCache(alias);
|
|
44
|
+
if (fromDisk) {
|
|
45
|
+
memCache.set(alias, fromDisk);
|
|
46
|
+
return fromDisk;
|
|
47
|
+
}
|
|
43
48
|
}
|
|
44
49
|
// Layer 3 — live query
|
|
45
50
|
const startedAt = Date.now();
|
|
@@ -152,25 +152,46 @@ export async function getSystemProfile(adt, alias, info, opts = {}) {
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
const timeoutMs = opts.readTimeoutMs ?? READ_TIMEOUT_MS;
|
|
155
|
+
// Part 2a D14 — an abort is not a result. Esc during the build returns null
|
|
156
|
+
// and touches NOTHING (no memory cache, no disk cache, no attempt marker,
|
|
157
|
+
// no audit row), so the next turn builds again. Any other failure keeps the
|
|
158
|
+
// path below exactly.
|
|
159
|
+
const signal = opts.signal;
|
|
160
|
+
if (signal?.aborted)
|
|
161
|
+
return null;
|
|
155
162
|
const startedAt = Date.now();
|
|
156
163
|
try {
|
|
157
164
|
let discovery = null;
|
|
165
|
+
// The GET carries a socket timeout and the turn's signal, so Esc (or a
|
|
166
|
+
// dead socket) frees the connection's request slot; `bounded` stays as the
|
|
167
|
+
// outer wall-clock guard for an adt that does not honour either.
|
|
158
168
|
try {
|
|
159
|
-
discovery = await bounded(adt.discovery(), timeoutMs);
|
|
169
|
+
discovery = await bounded(adt.discovery({ timeoutMs, signal }), timeoutMs);
|
|
160
170
|
}
|
|
161
|
-
catch {
|
|
171
|
+
catch (err) {
|
|
172
|
+
if (isAbort(err, signal))
|
|
173
|
+
return null;
|
|
162
174
|
discovery = null;
|
|
163
175
|
}
|
|
176
|
+
if (signal?.aborted)
|
|
177
|
+
return null;
|
|
164
178
|
let sp = null;
|
|
165
179
|
if (info?.components?.['SAP_BASIS'] && conn?.post) {
|
|
166
180
|
try {
|
|
167
|
-
|
|
181
|
+
// The signal rides post's 6th argument with no timeoutMs (the default
|
|
182
|
+
// socket timeout, as before). Without a signal the call is unchanged.
|
|
183
|
+
const spArgs = ['/sap/bc/adt/datapreview/freestyle?rowNumber=5', SP_SQL, 'text/plain', 'application/vnd.sap.adt.datapreview.table.v1+xml'];
|
|
184
|
+
const resp = await bounded(signal ? conn.post(...spArgs, undefined, signal) : conn.post(...spArgs), timeoutMs);
|
|
168
185
|
if (resp && resp.status === 200)
|
|
169
186
|
sp = parseExtRelease(resp.body);
|
|
170
187
|
}
|
|
171
|
-
catch {
|
|
188
|
+
catch (err) {
|
|
189
|
+
if (isAbort(err, signal))
|
|
190
|
+
return null;
|
|
172
191
|
sp = null;
|
|
173
192
|
}
|
|
193
|
+
if (signal?.aborted)
|
|
194
|
+
return null;
|
|
174
195
|
}
|
|
175
196
|
const profile = profileFrom({ alias, baseUrl, info, sp, discovery, now: now() });
|
|
176
197
|
if (!profile) {
|
|
@@ -199,6 +220,10 @@ export async function getSystemProfile(adt, alias, info, opts = {}) {
|
|
|
199
220
|
return null;
|
|
200
221
|
}
|
|
201
222
|
}
|
|
223
|
+
/** True for an `AbortError` from the request, or once the turn's signal has fired. */
|
|
224
|
+
function isAbort(err, signal) {
|
|
225
|
+
return signal?.aborted === true || err?.name === 'AbortError';
|
|
226
|
+
}
|
|
202
227
|
function sameSystem(p, baseUrl, info) {
|
|
203
228
|
if (baseUrl && p.baseUrl && p.baseUrl !== baseUrl)
|
|
204
229
|
return false;
|
package/dist/session/resume.js
CHANGED
|
@@ -14,6 +14,7 @@ import { interruptedPickerLine } from './interrupt-reason.js';
|
|
|
14
14
|
import stringWidth from 'string-width';
|
|
15
15
|
import { STANDALONE_ALIAS } from '../tools/index.js';
|
|
16
16
|
import { loadConfig, resolveStandalone } from '../config/loader.js';
|
|
17
|
+
import { clearProfilePin } from '../sap/profile-gate.js';
|
|
17
18
|
/** inquirer's Ctrl+C / force-close rejection. */
|
|
18
19
|
function isPromptCancel(err) {
|
|
19
20
|
return err?.name === 'ExitPromptError';
|
|
@@ -99,6 +100,8 @@ export async function runResume(id, opts = {}) {
|
|
|
99
100
|
let session;
|
|
100
101
|
try {
|
|
101
102
|
session = await loadSession(targetId);
|
|
103
|
+
// Part 2a D13 — re-pin the hidden tools on the first connected turn.
|
|
104
|
+
clearProfilePin(session);
|
|
102
105
|
}
|
|
103
106
|
catch (err) {
|
|
104
107
|
console.error('\n' + formatFailure({ what: 'Could not load the session', detail: [err.message], next: 'cspeach --resume (pick from the list)' }));
|
package/dist/tools/approval.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { registerTool } from './index.js';
|
|
3
3
|
import { isHeadless, shouldUseInk } from '../renderer/tty.js';
|
|
4
|
-
import { effectiveRisk, riskReasonText } from '../approvals/risk-floor.js';
|
|
4
|
+
import { effectiveRisk, riskReasonText, typeCode } from '../approvals/risk-floor.js';
|
|
5
5
|
import { objectsWithSyntaxErrors } from './syntax-state.js';
|
|
6
6
|
import { noteWriteTransport } from '../repl/current-transport.js';
|
|
7
7
|
/**
|
|
@@ -27,6 +27,7 @@ import { markPlanGateApproved } from '../repl/rule8-detector.js';
|
|
|
27
27
|
import { getEffectiveWriteMode } from '../repl/mode-cycle.js';
|
|
28
28
|
import { getCurrentTransport } from '../repl/current-transport.js';
|
|
29
29
|
import { displayTransport } from '../approvals/op-labels.js';
|
|
30
|
+
import { NOT_ON_THIS_RELEASE, approvalRefusedByProfile } from '../sap/profile-gate.js';
|
|
30
31
|
import { isGuardedRunActive, getCurrentPhaseWrites, notePlanDeviation, PLAN_DEVIATION_DETAIL, } from '../commands/plan-gate.js';
|
|
31
32
|
/**
|
|
32
33
|
* Advisory-only replacement for the per-change approval gauntlet.
|
|
@@ -139,6 +140,7 @@ registerTool({
|
|
|
139
140
|
type: { type: 'string' },
|
|
140
141
|
diff: { type: 'string', description: 'For a CREATE op this MUST be the exact, complete source that will be written — verbatim, character-for-character, NOT a description, summary, or paraphrase. It is shown to the developer in the approval box as the exact thing they are approving, so a one-line summary here means they approve a write they cannot inspect. For a MODIFY op, the real unified diff of the change (again verbatim, not a description).' },
|
|
141
142
|
package: { type: 'string', description: 'Target package (development class) for the object, if known — shown on the approval box' },
|
|
143
|
+
affects: { type: 'string', description: 'The standard SAP process this change runs inside, in plain words — REQUIRED for type ENHO (a BAdI implementation): name the BAdI, its spot and what calls it, e.g. "purchase order check before save (BAdI ME_PROCESS_PO_CUST, spot ES_MM_PO)". Shown on the approval card.' },
|
|
142
144
|
},
|
|
143
145
|
required: ['op', 'object', 'type'],
|
|
144
146
|
},
|
|
@@ -184,6 +186,23 @@ registerTool({
|
|
|
184
186
|
is_error: true,
|
|
185
187
|
};
|
|
186
188
|
}
|
|
189
|
+
// Part 2a D12 — never draw a card for a write this release cannot take.
|
|
190
|
+
if (ctx.systemProfile) {
|
|
191
|
+
const refused = await approvalRefusedByProfile(args.changes ?? [], ctx);
|
|
192
|
+
if (refused.length > 0) {
|
|
193
|
+
return {
|
|
194
|
+
content: JSON.stringify({ error: NOT_ON_THIS_RELEASE, approved: false, changes: refused,
|
|
195
|
+
message: `This system cannot take ${refused.length === 1 ? 'this change' : 'these changes'}: ${refused.map((r) => `${r.type} ${r.object} — ${r.message}`).join('; ')}` }),
|
|
196
|
+
is_error: true,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Part 2b D7 — an enhancement change never reaches a card without naming the process it runs in.
|
|
201
|
+
const unnamed = (args.changes ?? []).filter((c) => typeCode(c.type) === 'ENHO' && !String(c.affects ?? '').trim());
|
|
202
|
+
if (unnamed.length > 0) {
|
|
203
|
+
return { content: JSON.stringify({ error: 'affects_required', approved: false, changes: unnamed.map((c) => c.object),
|
|
204
|
+
message: 'A BAdI implementation changes a standard SAP process. Say which one in changes[].affects (the BAdI, its spot and what calls it), then ask again.' }), is_error: true };
|
|
205
|
+
}
|
|
187
206
|
let cfg = await loadConfig();
|
|
188
207
|
// Task 2 (ux-wave1) — the approval flow reads the EFFECTIVE write mode:
|
|
189
208
|
// config overlaid by the session override, clamped to the active alias's
|
package/dist/tools/sap-read.js
CHANGED
|
@@ -20,12 +20,15 @@
|
|
|
20
20
|
* runUnitTest(type, name)
|
|
21
21
|
* odataTest(servicePath, entity?, method?)
|
|
22
22
|
* badiList(query, enhancementSpot?, maxResults?)
|
|
23
|
+
* readBadiSpot(name) / readBadiImplementation(name) (Part 2b)
|
|
24
|
+
* enhancementOptions(type, name) (Part 2b)
|
|
23
25
|
*/
|
|
24
26
|
import { registerTool } from './index.js';
|
|
25
27
|
import { byObjectTypeForRead } from '../sap/profile-gate.js';
|
|
26
28
|
import { normaliseSyntaxResult, checkSyntax } from './verify.js';
|
|
27
29
|
import { recordSyntaxCheck } from './syntax-state.js';
|
|
28
30
|
import { spillToolResult } from './result-spill.js';
|
|
31
|
+
import { SapError } from '@cspeach/sap-client';
|
|
29
32
|
// -----------------------------------------------------------------------
|
|
30
33
|
// Task 17 (spec D12, ruling F18) — model-facing formats.
|
|
31
34
|
// -----------------------------------------------------------------------
|
|
@@ -746,7 +749,7 @@ registerTool({
|
|
|
746
749
|
// -----------------------------------------------------------------------
|
|
747
750
|
registerTool({
|
|
748
751
|
name: 'sap_badi_list',
|
|
749
|
-
description: 'Search for BAdI definitions and enhancement spots by name pattern (supports * wildcard), with optional enhancement-spot drill-down.',
|
|
752
|
+
description: 'Search for BAdI definitions and enhancement spots by name pattern (supports * wildcard), with optional enhancement-spot drill-down. Classic SE19 implementations are not available through ADT.',
|
|
750
753
|
isMutating: false,
|
|
751
754
|
requiresSap: true,
|
|
752
755
|
input_schema: {
|
|
@@ -772,3 +775,78 @@ registerTool({
|
|
|
772
775
|
return { content: JSON.stringify(result, null, 2) };
|
|
773
776
|
},
|
|
774
777
|
});
|
|
778
|
+
// -----------------------------------------------------------------------
|
|
779
|
+
// 19. sap_badi_read (Part 2b) — one new-style spot or implementation
|
|
780
|
+
// -----------------------------------------------------------------------
|
|
781
|
+
const isSapNotFound = (e) => e instanceof SapError && e.httpStatus === 404;
|
|
782
|
+
const notFound = (detail) => ({ content: JSON.stringify({ error: 'not_found', detail }), is_error: true });
|
|
783
|
+
registerTool({
|
|
784
|
+
name: 'sap_badi_read',
|
|
785
|
+
description: 'Read one new-style BAdI enhancement spot (its BAdI definitions and their interfaces) or one BAdI implementation '
|
|
786
|
+
+ '(its spot, BAdI definition and implementing class). Classic SE18/SE19 objects are not available through ADT.',
|
|
787
|
+
isMutating: false,
|
|
788
|
+
requiresSap: true,
|
|
789
|
+
input_schema: {
|
|
790
|
+
type: 'object',
|
|
791
|
+
properties: {
|
|
792
|
+
kind: { type: 'string', enum: ['spot', 'implementation'], description: 'spot = an enhancement spot; implementation = a BAdI implementation' },
|
|
793
|
+
name: { type: 'string', description: 'Name of the spot or implementation' },
|
|
794
|
+
},
|
|
795
|
+
required: ['kind', 'name'],
|
|
796
|
+
},
|
|
797
|
+
handler: async (args, ctx) => {
|
|
798
|
+
const name = String(args.name ?? '').trim().toUpperCase();
|
|
799
|
+
const spot = args.kind === 'spot';
|
|
800
|
+
try {
|
|
801
|
+
const info = spot ? await ctx.adt.readBadiSpot(args.name) : await ctx.adt.readBadiImplementation(args.name);
|
|
802
|
+
return { content: JSON.stringify(info, null, 2) };
|
|
803
|
+
}
|
|
804
|
+
catch (e) {
|
|
805
|
+
if (!isSapNotFound(e))
|
|
806
|
+
throw e;
|
|
807
|
+
return notFound(spot
|
|
808
|
+
? `${name} is not a new-style BAdI enhancement spot. Classic BAdI definitions (SE18 without an enhancement spot) are not available through ADT.`
|
|
809
|
+
: `${name} is not a new-style BAdI implementation. A classic SE19 implementation is not available through ADT; CSPeach does not create or change those.`);
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
});
|
|
813
|
+
// -----------------------------------------------------------------------
|
|
814
|
+
// 20. sap_enhancement_options (Part 2b) — implicit enhancement options, read only
|
|
815
|
+
// -----------------------------------------------------------------------
|
|
816
|
+
registerTool({
|
|
817
|
+
name: 'sap_enhancement_options',
|
|
818
|
+
description: 'List the implicit enhancement options of a program, class or function group (full name, description, mode). '
|
|
819
|
+
+ 'Read only: CSPeach does not write implicit enhancements.',
|
|
820
|
+
isMutating: false,
|
|
821
|
+
requiresSap: true,
|
|
822
|
+
input_schema: {
|
|
823
|
+
type: 'object',
|
|
824
|
+
properties: {
|
|
825
|
+
type: { type: 'string', enum: ['PROG', 'CLAS', 'FUGR'], description: 'Object type: PROG, CLAS or FUGR' },
|
|
826
|
+
name: { type: 'string', description: 'Object name' },
|
|
827
|
+
max: { type: 'number', description: 'Most options to return (default 200)' },
|
|
828
|
+
},
|
|
829
|
+
required: ['type', 'name'],
|
|
830
|
+
},
|
|
831
|
+
handler: async (args, ctx) => {
|
|
832
|
+
const type = String(args.type ?? '').trim().toUpperCase();
|
|
833
|
+
const name = String(args.name ?? '').trim().toUpperCase();
|
|
834
|
+
const max = Number.isFinite(args.max) && args.max >= 1 ? Math.floor(args.max) : 200;
|
|
835
|
+
let all;
|
|
836
|
+
try {
|
|
837
|
+
all = await ctx.adt.enhancementOptions(type, args.name);
|
|
838
|
+
}
|
|
839
|
+
catch (e) {
|
|
840
|
+
if (!isSapNotFound(e))
|
|
841
|
+
throw e;
|
|
842
|
+
return notFound(`${type} ${name} does not exist.`);
|
|
843
|
+
}
|
|
844
|
+
return {
|
|
845
|
+
content: JSON.stringify({
|
|
846
|
+
object: name, type, count: all.length,
|
|
847
|
+
options: all.slice(0, max).map((o) => ({ full_name: o.fullName, description: o.description, mode: o.mode })),
|
|
848
|
+
...(all.length > max ? { truncated: true } : {}),
|
|
849
|
+
}),
|
|
850
|
+
};
|
|
851
|
+
},
|
|
852
|
+
});
|