@cspeach/cli 1.1.39 → 1.1.41
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 +19 -2
- package/dist/agent/tool-dispatch.js +17 -0
- package/dist/sap/profile-features.js +42 -0
- package/dist/sap/profile-gate.js +105 -0
- package/dist/sap/system-info.js +16 -11
- package/dist/sap/system-profile.js +337 -0
- package/dist/session/resume.js +3 -0
- package/dist/tools/approval.js +12 -0
- package/dist/tools/rap/finish-check.js +1 -0
- package/dist/tools/rap/generate-objects.js +1 -0
- package/dist/tools/sap-read.js +6 -0
- package/dist/tools/sap-write.js +527 -1217
- package/dist/tools/text-elements/tools.js +2 -0
- package/dist/tools/verify.js +63 -14
- package/dist/tools/write-pipeline.js +437 -0
- package/package.json +2 -2
package/dist/agent/loop.js
CHANGED
|
@@ -57,6 +57,8 @@ import { expandTextFileAttachments } from '../projects/workspace.js';
|
|
|
57
57
|
import { expandImageAttachments, buildUserContent, trimHistoryImages } from '../projects/image-attachments.js';
|
|
58
58
|
import { expandImageChips } from '../ui/clipboard-image.js';
|
|
59
59
|
import { getSapSystemInfo, renderSapSystemBlock } from '../sap/system-info.js';
|
|
60
|
+
import { getSystemProfile, renderSapProfileBlock } from '../sap/system-profile.js';
|
|
61
|
+
import { pinHiddenTools, withoutTools } from '../sap/profile-gate.js';
|
|
60
62
|
import { renderStandaloneContextBlock } from '../sap/standalone-profile.js';
|
|
61
63
|
import { renderStandardsBlock } from '../standards/standards-file.js';
|
|
62
64
|
import { composeSessionContextBlock } from './session-context.js';
|
|
@@ -820,6 +822,7 @@ ${imageExpansion.error}`));
|
|
|
820
822
|
// SAP block is then empty and the rest of enrichment proceeds as
|
|
821
823
|
// before — no behavioral regression.
|
|
822
824
|
let sapSystemBlock = '';
|
|
825
|
+
let sapProfileBlock = '';
|
|
823
826
|
let sapSystemInfo = null;
|
|
824
827
|
const standaloneTurn = isStandalone(params.ctx);
|
|
825
828
|
if (standaloneTurn) {
|
|
@@ -828,10 +831,17 @@ ${imageExpansion.error}`));
|
|
|
828
831
|
// so skills read one element in both modes — plus the manual-
|
|
829
832
|
// implementation hand-off note.
|
|
830
833
|
sapSystemBlock = renderStandaloneContextBlock(cfg.standalone);
|
|
834
|
+
params.ctx.systemProfile = null;
|
|
831
835
|
}
|
|
832
836
|
else if (params.ctx.adt && params.ctx.sapAlias) {
|
|
833
837
|
sapSystemInfo = await getSapSystemInfo(params.ctx.adt, params.ctx.sapAlias);
|
|
838
|
+
// ADT gap program, Part 1 — the system profile rides the same lazy probe.
|
|
839
|
+
// Null (kill switch, no discovery and no CVERS) = today's behaviour.
|
|
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 });
|
|
834
843
|
sapSystemBlock = renderSapSystemBlock(sapSystemInfo);
|
|
844
|
+
sapProfileBlock = renderSapProfileBlock(params.ctx.systemProfile);
|
|
835
845
|
}
|
|
836
846
|
// Customer development standards — opt-in, both modes. Renders '' when the
|
|
837
847
|
// user has no standards file, so the composed block (and therefore the whole
|
|
@@ -842,7 +852,7 @@ ${imageExpansion.error}`));
|
|
|
842
852
|
const switchNote = session.systemSwitchNote
|
|
843
853
|
? `<system_switch>${session.systemSwitchNote}</system_switch>`
|
|
844
854
|
: '';
|
|
845
|
-
const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, standardsBlock, switchNote]);
|
|
855
|
+
const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, sapProfileBlock, standardsBlock, switchNote]);
|
|
846
856
|
// Deterministically extract structured facts from the user's prompt
|
|
847
857
|
// (package, transport, object names, environment hint, create intent)
|
|
848
858
|
// and prepend them as an XML `<session_context>` block. The model reads
|
|
@@ -926,7 +936,14 @@ ${imageExpansion.error}`));
|
|
|
926
936
|
env: process.env,
|
|
927
937
|
served: servedToolLoading(),
|
|
928
938
|
});
|
|
929
|
-
|
|
939
|
+
// Part 1 (D4, R7) — the hidden set is pinned per alias on the first
|
|
940
|
+
// connected turn and reused, so tools[] stays byte-stable; [] pinned = same
|
|
941
|
+
// array as today. A standalone or unconnected turn pins nothing.
|
|
942
|
+
const connectedTurn = !standaloneTurn && !!params.ctx.adt && !!params.ctx.sapAlias;
|
|
943
|
+
const hiddenSapTools = connectedTurn
|
|
944
|
+
? pinHiddenTools(session, params.ctx.sapAlias, listTools(), params.ctx.systemProfile ?? null)
|
|
945
|
+
: [];
|
|
946
|
+
const contextTools = withoutTools(toolsForContext(listTools(), params.ctx), hiddenSapTools);
|
|
930
947
|
// Task 18 fixes M4 / M5 — no read_agent for an agent_run child, and none
|
|
931
948
|
// when a reader would have no real read tool (standalone, file tools off).
|
|
932
949
|
const visibleTools = params.ctx.subagent || !hasReaderReadTools(new Set(contextTools.map((t) => t.name)))
|
|
@@ -9,6 +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, gateWithVerify } from '../sap/profile-gate.js';
|
|
13
|
+
import { INTERRUPTED_TOOL_RESULT } from './repair-partial.js';
|
|
12
14
|
const READER_TOOL_SET = new Set(READER_TOOLS);
|
|
13
15
|
export async function dispatchTool(name, args, ctx) {
|
|
14
16
|
const tool = getTool(name);
|
|
@@ -36,6 +38,21 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
36
38
|
is_error: true,
|
|
37
39
|
};
|
|
38
40
|
}
|
|
41
|
+
// ADT gap program, Part 1 (D4) — a definite "not on this release" is
|
|
42
|
+
// answered here, before any SAP call, as a normal tool error. Unknown or
|
|
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);
|
|
46
|
+
if (gate) {
|
|
47
|
+
return {
|
|
48
|
+
content: JSON.stringify({ error: NOT_ON_THIS_RELEASE, feature: gate.feature, message: gate.message }),
|
|
49
|
+
is_error: true,
|
|
50
|
+
};
|
|
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 };
|
|
39
56
|
// L1-6 R1-6 (owner decision 2026-09-21) — a call that hands the stored SAP
|
|
40
57
|
// login to a local child process ALWAYS asks a human, once per session per
|
|
41
58
|
// alias, even as the first mutating call of a turn (where Rule 8 below stays
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const A = '/sap/bc/adt';
|
|
2
|
+
export const FEATURES = Object.freeze({
|
|
3
|
+
'cds.ddls': { label: 'CDS data definitions', collection: `${A}/ddic/ddl/sources`, since: { basis: 740, sp: 5, label: 'ABAP 7.40 SP05' }, provenOnOurBox: true },
|
|
4
|
+
'cds.viewEntity': { label: 'CDS view entities (define view entity)', since: { basis: 755, label: 'S/4HANA 2020 (ABAP 7.55)' }, provenOnOurBox: true },
|
|
5
|
+
'cds.tableEntity': { label: 'CDS table entities (define table entity)', since: { basis: 816, label: 'S/4HANA 2025 (ABAP 8.16)' }, provenOnOurBox: false },
|
|
6
|
+
'cds.ddlx': { label: 'CDS metadata extensions', collection: `${A}/ddic/ddlx/sources`, since: { basis: 751, label: 'ABAP 7.51' }, provenOnOurBox: true },
|
|
7
|
+
'cds.dcls': { label: 'CDS access controls (DCL)', collection: `${A}/acm/dcl/sources`, since: { basis: 740, sp: 10, label: 'ABAP 7.40 SP10' }, provenOnOurBox: true },
|
|
8
|
+
'rap.bdef': { label: 'Behavior definitions (RAP)', collection: `${A}/bo/behaviordefinitions`, since: { basis: 753, label: 'S/4HANA 1809 (ABAP 7.53)' }, provenOnOurBox: true },
|
|
9
|
+
// Final review I1 (owner rule: a wrong no is worse than unknown): the next six
|
|
10
|
+
// rows had release floors with no cited source (gap table §3.1 gives one date
|
|
11
|
+
// for five RAP/CDS objects; "1709" for table/structure; "1610 / 7.50" for
|
|
12
|
+
// domain/data element). Dropped: listed = yes, else unknown, never no. Add a
|
|
13
|
+
// floor back only with a citation (SAP Help release line or ADT feature matrix).
|
|
14
|
+
'rap.srvd': { label: 'Service definitions', collection: `${A}/ddic/srvd/sources`, provenOnOurBox: true },
|
|
15
|
+
'rap.srvb': { label: 'Service bindings', collection: `${A}/businessservices/bindings`, provenOnOurBox: true },
|
|
16
|
+
'ddic.tables': { label: 'Tables in ADT', collection: `${A}/ddic/tables`, provenOnOurBox: true },
|
|
17
|
+
'ddic.structures': { label: 'Structures in ADT', collection: `${A}/ddic/structures`, provenOnOurBox: true },
|
|
18
|
+
'ddic.domains': { label: 'Domains in ADT', collection: `${A}/ddic/domains`, provenOnOurBox: true },
|
|
19
|
+
'ddic.dataelements': { label: 'Data elements in ADT', collection: `${A}/ddic/dataelements`, provenOnOurBox: true },
|
|
20
|
+
'generator.repository': { label: "SAP's RAP generator", collection: `${A}/repository/generators`, since: { basis: 757, label: 'S/4HANA 2022 (ABAP 7.57)' }, absentMeansNo: true, provenOnOurBox: true },
|
|
21
|
+
'atc.worklists': { label: 'ATC checks', collection: `${A}/atc/worklists`, since: { basis: 740, label: 'ABAP 7.40' }, provenOnOurBox: true },
|
|
22
|
+
'unit.testruns': { label: 'ABAP Unit runs', collection: `${A}/abapunit/testruns`, provenOnOurBox: true },
|
|
23
|
+
'text.programs': { label: 'Text elements of programs', collection: `${A}/textelements/programs`, since: { basis: 740, sp: 8, label: 'ABAP 7.40 SP08' }, provenOnOurBox: true },
|
|
24
|
+
// No `since` (R4): the profile's own CVERS reads use this endpoint, so a
|
|
25
|
+
// release-based `no` could only ever be wrong. Listed = yes, else unknown.
|
|
26
|
+
'datapreview.freestyle': { label: 'SQL data preview', collection: `${A}/datapreview/freestyle`, provenOnOurBox: true },
|
|
27
|
+
'msag': { label: 'Message classes', collection: `${A}/messageclass`, provenOnOurBox: true },
|
|
28
|
+
'nrob': { label: 'Number range objects', collection: `${A}/numberranges/objects`, provenOnOurBox: true },
|
|
29
|
+
});
|
|
30
|
+
export const FEATURE_IDS = Object.freeze(Object.keys(FEATURES));
|
|
31
|
+
export function isFeatureId(s) {
|
|
32
|
+
return Object.prototype.hasOwnProperty.call(FEATURES, s);
|
|
33
|
+
}
|
|
34
|
+
/** url-mapper type token (upper case) → the feature it needs. Unlisted types need nothing. */
|
|
35
|
+
export const OBJECT_TYPE_FEATURE = Object.freeze({
|
|
36
|
+
BDEF: 'rap.bdef', SRVD: 'rap.srvd', SRVB: 'rap.srvb',
|
|
37
|
+
DDLS: 'cds.ddls', CDS: 'cds.ddls', DDLX: 'cds.ddlx', DCLS: 'cds.dcls',
|
|
38
|
+
TABL: 'ddic.tables', STRU: 'ddic.structures', DOMA: 'ddic.domains', DTEL: 'ddic.dataelements',
|
|
39
|
+
MSAG: 'msag', NROB: 'nrob',
|
|
40
|
+
});
|
|
41
|
+
/** The RAP/CDS subset: the only types a READ is ever refused for (R6). */
|
|
42
|
+
export const READ_GATED_TYPES = new Set(['BDEF', 'SRVD', 'SRVB', 'DDLS', 'CDS', 'DDLX', 'DCLS']);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { FEATURES, OBJECT_TYPE_FEATURE, READ_GATED_TYPES } from './profile-features.js';
|
|
2
|
+
import { getSystemProfile, supports, systemLabel } from './system-profile.js';
|
|
3
|
+
import { getSapSystemInfo } from './system-info.js';
|
|
4
|
+
export const NOT_ON_THIS_RELEASE = 'not_on_this_release';
|
|
5
|
+
/** For tools whose `type` argument names an object type (url-mapper token). */
|
|
6
|
+
export function byObjectType(args) {
|
|
7
|
+
const t = typeof args?.type === 'string' ? args.type.trim().toUpperCase() : '';
|
|
8
|
+
return t && Object.prototype.hasOwnProperty.call(OBJECT_TYPE_FEATURE, t) ? OBJECT_TYPE_FEATURE[t] : null;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* R6 — reads refuse only RAP/CDS types (they cannot exist below their release).
|
|
12
|
+
* DDIC types carry no release floor at all (final review I1); a DDIC read was never proven to fail, and a
|
|
13
|
+
* wrong read costs one 404 while a wrong `no` costs the user's work.
|
|
14
|
+
*/
|
|
15
|
+
export function byObjectTypeForRead(args) {
|
|
16
|
+
const t = typeof args?.type === 'string' ? args.type.trim().toUpperCase() : '';
|
|
17
|
+
return READ_GATED_TYPES.has(t) ? byObjectType(args) : null;
|
|
18
|
+
}
|
|
19
|
+
export function requiredFeature(tool, args) {
|
|
20
|
+
const r = tool.requires;
|
|
21
|
+
if (!r)
|
|
22
|
+
return null;
|
|
23
|
+
return typeof r === 'function' ? r(args) : r;
|
|
24
|
+
}
|
|
25
|
+
export function profileGate(tool, args, profile) {
|
|
26
|
+
if (!profile)
|
|
27
|
+
return null;
|
|
28
|
+
const feature = requiredFeature(tool, args);
|
|
29
|
+
if (!feature)
|
|
30
|
+
return null;
|
|
31
|
+
const v = supports(profile, feature);
|
|
32
|
+
if (v.answer !== 'no')
|
|
33
|
+
return null;
|
|
34
|
+
// I2 — name when the facts were read: the profile is kept up to 30 days.
|
|
35
|
+
return { feature, message: `${FEATURES[feature].label}: ${v.reason}. This system: ${systemLabel(profile)}, read ${profile.builtAt.slice(0, 10)}.` };
|
|
36
|
+
}
|
|
37
|
+
/** Names of the tools whose FIXED feature is a definite no. [] for no profile. */
|
|
38
|
+
export function hiddenByProfile(tools, profile) {
|
|
39
|
+
if (!profile)
|
|
40
|
+
return [];
|
|
41
|
+
return tools.filter((t) => typeof t.requires === 'string' && supports(profile, t.requires).answer === 'no').map((t) => t.name);
|
|
42
|
+
}
|
|
43
|
+
/** Drop the named tools. Same array back when nothing changes (byte-identical path). */
|
|
44
|
+
export function withoutTools(tools, hidden) {
|
|
45
|
+
if (hidden.length === 0)
|
|
46
|
+
return tools;
|
|
47
|
+
const set = new Set(hidden);
|
|
48
|
+
const kept = tools.filter((t) => !set.has(t.name));
|
|
49
|
+
return kept.length === tools.length ? tools : kept;
|
|
50
|
+
}
|
|
51
|
+
export function toolsForProfile(tools, profile) {
|
|
52
|
+
return withoutTools(tools, hiddenByProfile(tools, profile));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* R7 / D4 — tools[] must be byte-stable for the session (prompt cache,
|
|
56
|
+
* preserved thinking), but it is rebuilt every turn. The hidden set is pinned
|
|
57
|
+
* on the session the first time an alias is seen — whatever the profile was
|
|
58
|
+
* on that turn (null pins []) — and reused after that. Another alias re-pins.
|
|
59
|
+
* A profile that arrives later refuses per call at dispatch instead.
|
|
60
|
+
*/
|
|
61
|
+
export function pinHiddenTools(session, alias, tools, profile) {
|
|
62
|
+
const pinned = session.profile_hidden_tools;
|
|
63
|
+
if (pinned && pinned.alias === alias)
|
|
64
|
+
return pinned.names;
|
|
65
|
+
const names = hiddenByProfile(tools, profile);
|
|
66
|
+
session.profile_hidden_tools = { alias, names };
|
|
67
|
+
return names;
|
|
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();
|
|
@@ -535,6 +540,6 @@ export function renderSapSystemBlock(info) {
|
|
|
535
540
|
].join(' ');
|
|
536
541
|
return `<sap_system ${attrs} />`;
|
|
537
542
|
}
|
|
538
|
-
function escapeAttr(s) {
|
|
543
|
+
export function escapeAttr(s) {
|
|
539
544
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
540
545
|
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// cspeach-cli/src/sap/system-profile.ts
|
|
2
|
+
/**
|
|
3
|
+
* System profile (ADT gap program, Part 1; spec §2.1).
|
|
4
|
+
*
|
|
5
|
+
* One profile per connected SAP system, built from the facts the CLI already
|
|
6
|
+
* has (system-info: CVERS release) plus two reads cached for 30 days: the ADT
|
|
7
|
+
* discovery document and the SAP_BASIS support pack. `supports()` answers
|
|
8
|
+
* yes / no / unknown with a plain reason. Unknown changes nothing: tools run
|
|
9
|
+
* as today and fail at HTTP time if the feature is missing. Only a definite
|
|
10
|
+
* `no` stops a call before it reaches SAP.
|
|
11
|
+
*
|
|
12
|
+
* Evidence rules (owner decision D1): a collection LISTED by discovery is a
|
|
13
|
+
* yes; an unlisted one proves nothing (our S/4HANA 2023 box omits endpoints
|
|
14
|
+
* that work). A `no` comes from the release table alone, except the generator,
|
|
15
|
+
* which keeps today's getGeneratorFact reading (absent = no = CSPeach writer).
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { auditLog } from '@cspeach/sap-client';
|
|
20
|
+
import { cspeachRoot } from '../config/paths.js';
|
|
21
|
+
import { escapeAttr } from './system-info.js';
|
|
22
|
+
import { FEATURES, FEATURE_IDS } from './profile-features.js';
|
|
23
|
+
export function releaseAtLeast(p, min) {
|
|
24
|
+
if (p.basis === null)
|
|
25
|
+
return null;
|
|
26
|
+
if (p.basis > min.basis)
|
|
27
|
+
return true;
|
|
28
|
+
if (p.basis < min.basis)
|
|
29
|
+
return false;
|
|
30
|
+
if (min.sp === undefined)
|
|
31
|
+
return true;
|
|
32
|
+
if (p.sp === null)
|
|
33
|
+
return null;
|
|
34
|
+
return p.sp >= min.sp;
|
|
35
|
+
}
|
|
36
|
+
export function systemLabel(p) {
|
|
37
|
+
if (p.basis === null && p.release === 'unknown')
|
|
38
|
+
return 'unknown release';
|
|
39
|
+
const sp = p.sp === null ? '' : ` SP${String(p.sp).padStart(2, '0')}`;
|
|
40
|
+
const abap = p.abapVersion === 'unknown' ? '' : ` (ABAP ${p.abapVersion})`;
|
|
41
|
+
return `${p.release}${sp}${abap}`;
|
|
42
|
+
}
|
|
43
|
+
export function supports(p, id) {
|
|
44
|
+
const rule = FEATURES[id];
|
|
45
|
+
if (!p)
|
|
46
|
+
return { answer: 'unknown', reason: 'system profile not available' };
|
|
47
|
+
const listed = !!rule.collection && p.discovery.read && p.discovery.collections.includes(rule.collection);
|
|
48
|
+
if (listed)
|
|
49
|
+
return { answer: 'yes', reason: 'listed by this system' };
|
|
50
|
+
const byRelease = rule.since ? releaseAtLeast(p, rule.since) : null;
|
|
51
|
+
if (rule.since && byRelease === false)
|
|
52
|
+
return { answer: 'no', reason: `needs ${rule.since.label} or later` };
|
|
53
|
+
// R3 — the generator must agree with getGeneratorFact for the same body:
|
|
54
|
+
// list unread = fact unknown (HTTP ≥ 400) or no (HTML page), never a yes by release.
|
|
55
|
+
if (rule.absentMeansNo && rule.collection && !p.discovery.read)
|
|
56
|
+
return { answer: 'unknown', reason: 'the system list could not be read' };
|
|
57
|
+
if (rule.absentMeansNo && rule.collection && p.discovery.read)
|
|
58
|
+
return { answer: 'no', reason: 'not offered by this system' };
|
|
59
|
+
if (rule.since && byRelease === true)
|
|
60
|
+
return { answer: 'yes', reason: `${systemLabel(p)} has it` };
|
|
61
|
+
if (rule.since && p.basis !== null && p.basis === rule.since.basis && p.sp === null) {
|
|
62
|
+
return { answer: 'unknown', reason: `needs ${rule.since.label}; this system is ${p.abapVersion} with an unknown support pack` };
|
|
63
|
+
}
|
|
64
|
+
if (!p.discovery.read && rule.collection)
|
|
65
|
+
return { answer: 'unknown', reason: 'the system list could not be read' };
|
|
66
|
+
return { answer: 'unknown', reason: 'no release fact covers it' };
|
|
67
|
+
}
|
|
68
|
+
function basisOf(info) {
|
|
69
|
+
const raw = info?.components?.['SAP_BASIS'];
|
|
70
|
+
if (!raw || !/^\d{3}$/.test(raw.trim()))
|
|
71
|
+
return null;
|
|
72
|
+
return Number(raw.trim());
|
|
73
|
+
}
|
|
74
|
+
export function profileFrom(i) {
|
|
75
|
+
if (!i.info && !i.discovery)
|
|
76
|
+
return null;
|
|
77
|
+
const basis = basisOf(i.info);
|
|
78
|
+
return {
|
|
79
|
+
alias: i.alias,
|
|
80
|
+
baseUrl: i.baseUrl,
|
|
81
|
+
platform: i.info?.platform ?? 'unknown',
|
|
82
|
+
release: i.info?.release ?? 'unknown',
|
|
83
|
+
abapVersion: i.info?.abapVersion ?? 'unknown',
|
|
84
|
+
basis,
|
|
85
|
+
sp: basis === null ? null : i.sp,
|
|
86
|
+
cloud: i.info && Object.keys(i.info.components).length > 0 ? 'no' : 'unknown',
|
|
87
|
+
discovery: i.discovery
|
|
88
|
+
? { read: true, collections: Object.keys(i.discovery.collections).sort() }
|
|
89
|
+
: { read: false, collections: [] },
|
|
90
|
+
builtAt: i.now.toISOString(),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
94
|
+
/** Bump when the stored shape or its meaning changes. */
|
|
95
|
+
const CACHE_VERSION = 1;
|
|
96
|
+
const SP_SQL = "SELECT COMPONENT, RELEASE, EXTRELEASE FROM CVERS WHERE COMPONENT = 'SAP_BASIS'";
|
|
97
|
+
/**
|
|
98
|
+
* Final review I4 — an incomplete build (list or SP not read) is never written
|
|
99
|
+
* as a profile (R2), so without this it would cost both reads on every CLI
|
|
100
|
+
* start. The ATTEMPT is remembered per alias for a day (when, and against
|
|
101
|
+
* which system — no answers), and each read is bounded in time.
|
|
102
|
+
*/
|
|
103
|
+
const ATTEMPT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
104
|
+
const READ_TIMEOUT_MS = 15_000;
|
|
105
|
+
/** Last build per alias for this process — null included (X3: a failed turn is not retried every turn). */
|
|
106
|
+
const memCache = new Map();
|
|
107
|
+
export function _resetSystemProfileCache() {
|
|
108
|
+
memCache.clear();
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The SAP_BASIS support pack from the column-major data preview ('0002' → 2).
|
|
112
|
+
* Sibling of parseCvers (system-info.ts), which reads the same shape into
|
|
113
|
+
* component → release; that function and its SQL stay untouched (D2).
|
|
114
|
+
*/
|
|
115
|
+
export function parseExtRelease(xml) {
|
|
116
|
+
const colsRe = /<(?:[\w:]+:)?columns\b[^>]*>([\s\S]*?)<\/(?:[\w:]+:)?columns\s*>/g;
|
|
117
|
+
let m;
|
|
118
|
+
while ((m = colsRe.exec(xml)) !== null) {
|
|
119
|
+
const block = m[1];
|
|
120
|
+
const name = /<(?:[\w:]+:)?metadata\b[^>]*?\b(?:[\w:]+:)?name="([^"]+)"/i.exec(block)?.[1];
|
|
121
|
+
if (!name || name.toUpperCase() !== 'EXTRELEASE')
|
|
122
|
+
continue;
|
|
123
|
+
const value = /<(?:[\w:]+:)?data\b(?:\s[^>]*?)?>([\s\S]*?)<\/(?:[\w:]+:)?data\s*>/.exec(block)?.[1]?.trim() ?? '';
|
|
124
|
+
const digits = /(\d+)\s*$/.exec(value)?.[1];
|
|
125
|
+
return digits ? Number(digits) : null;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
export async function getSystemProfile(adt, alias, info, opts = {}) {
|
|
130
|
+
const env = opts.env ?? process.env;
|
|
131
|
+
if ((env['CSPEACH_SYSTEM_PROFILE'] ?? '').trim().toLowerCase() === 'off')
|
|
132
|
+
return null;
|
|
133
|
+
const now = opts.now ?? (() => new Date());
|
|
134
|
+
const conn = adt.connection;
|
|
135
|
+
const baseUrl = conn?.getBaseUrl?.() ?? '';
|
|
136
|
+
if (!opts.fresh) {
|
|
137
|
+
if (memCache.has(alias)) {
|
|
138
|
+
const hit = memCache.get(alias) ?? null;
|
|
139
|
+
if (hit === null || sameSystem(hit, baseUrl, info))
|
|
140
|
+
return hit;
|
|
141
|
+
}
|
|
142
|
+
const fromDisk = readDiskCache(alias, now());
|
|
143
|
+
if (fromDisk && sameSystem(fromDisk, baseUrl, info)) {
|
|
144
|
+
memCache.set(alias, fromDisk);
|
|
145
|
+
return fromDisk;
|
|
146
|
+
}
|
|
147
|
+
// I4 — an incomplete build was tried for this system within the day: no
|
|
148
|
+
// profile this start (unknown everywhere = today's behaviour), no requests.
|
|
149
|
+
if (recentAttempt(alias, baseUrl, info, now())) {
|
|
150
|
+
memCache.set(alias, null);
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
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;
|
|
162
|
+
const startedAt = Date.now();
|
|
163
|
+
try {
|
|
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.
|
|
168
|
+
try {
|
|
169
|
+
discovery = await bounded(adt.discovery({ timeoutMs, signal }), timeoutMs);
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
if (isAbort(err, signal))
|
|
173
|
+
return null;
|
|
174
|
+
discovery = null;
|
|
175
|
+
}
|
|
176
|
+
if (signal?.aborted)
|
|
177
|
+
return null;
|
|
178
|
+
let sp = null;
|
|
179
|
+
if (info?.components?.['SAP_BASIS'] && conn?.post) {
|
|
180
|
+
try {
|
|
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);
|
|
185
|
+
if (resp && resp.status === 200)
|
|
186
|
+
sp = parseExtRelease(resp.body);
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
if (isAbort(err, signal))
|
|
190
|
+
return null;
|
|
191
|
+
sp = null;
|
|
192
|
+
}
|
|
193
|
+
if (signal?.aborted)
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const profile = profileFrom({ alias, baseUrl, info, sp, discovery, now: now() });
|
|
197
|
+
if (!profile) {
|
|
198
|
+
memCache.set(alias, null);
|
|
199
|
+
writeAttempt(alias, baseUrl, info, now());
|
|
200
|
+
await auditLog('sapProfile', 'ALIAS', alias, 'error', Date.now() - startedAt, 'no discovery and no system info');
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
memCache.set(alias, profile);
|
|
204
|
+
// R2 — same rule as system-info's failed probe: never persist what was not
|
|
205
|
+
// read. Complete = list read, and the SP read (or no SAP_BASIS to read it for).
|
|
206
|
+
const complete = profile.discovery.read && (profile.sp !== null || profile.basis === null);
|
|
207
|
+
if (complete) {
|
|
208
|
+
writeDiskCache(alias, profile);
|
|
209
|
+
clearAttempt(alias);
|
|
210
|
+
}
|
|
211
|
+
else
|
|
212
|
+
writeAttempt(alias, baseUrl, info, now());
|
|
213
|
+
await auditLog('sapProfile', 'ALIAS', alias, 'success', Date.now() - startedAt, `release=${profile.release} sp=${profile.sp ?? 'unknown'} discovery=${profile.discovery.read ? profile.discovery.collections.length : 'not read'} cached=${complete ? 'disk' : 'memory'}`);
|
|
214
|
+
return profile;
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
memCache.set(alias, null);
|
|
218
|
+
writeAttempt(alias, baseUrl, info, now());
|
|
219
|
+
await auditLog('sapProfile', 'ALIAS', alias, 'error', Date.now() - startedAt, `threw: ${err.message}`);
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
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
|
+
}
|
|
227
|
+
function sameSystem(p, baseUrl, info) {
|
|
228
|
+
if (baseUrl && p.baseUrl && p.baseUrl !== baseUrl)
|
|
229
|
+
return false;
|
|
230
|
+
if (!info)
|
|
231
|
+
return true;
|
|
232
|
+
return p.release === info.release && p.abapVersion === info.abapVersion;
|
|
233
|
+
}
|
|
234
|
+
function cacheFile(alias, kind = 'profile') {
|
|
235
|
+
const safe = alias.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
236
|
+
return join(cspeachRoot(), 'sap-cache', `${safe}.${kind}.json`);
|
|
237
|
+
}
|
|
238
|
+
/** Resolves to null when the read takes longer than `ms` (the request itself is left to finish). */
|
|
239
|
+
function bounded(p, ms) {
|
|
240
|
+
let timer;
|
|
241
|
+
const timeout = new Promise((resolve) => {
|
|
242
|
+
timer = setTimeout(() => resolve(null), ms);
|
|
243
|
+
timer.unref?.();
|
|
244
|
+
});
|
|
245
|
+
return Promise.race([p, timeout]).finally(() => { if (timer)
|
|
246
|
+
clearTimeout(timer); });
|
|
247
|
+
}
|
|
248
|
+
function recentAttempt(alias, baseUrl, info, now) {
|
|
249
|
+
try {
|
|
250
|
+
const path = cacheFile(alias, 'profile-attempt');
|
|
251
|
+
if (!existsSync(path))
|
|
252
|
+
return false;
|
|
253
|
+
const a = JSON.parse(readFileSync(path, 'utf8'));
|
|
254
|
+
const at = new Date(a.attemptedAt).getTime();
|
|
255
|
+
if (!Number.isFinite(at) || now.getTime() - at > ATTEMPT_TTL_MS || now.getTime() < at)
|
|
256
|
+
return false;
|
|
257
|
+
if (a.baseUrl !== baseUrl)
|
|
258
|
+
return false;
|
|
259
|
+
return a.release === (info?.release ?? 'unknown') && a.abapVersion === (info?.abapVersion ?? 'unknown');
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function writeAttempt(alias, baseUrl, info, now) {
|
|
266
|
+
try {
|
|
267
|
+
const dir = join(cspeachRoot(), 'sap-cache');
|
|
268
|
+
if (!existsSync(dir))
|
|
269
|
+
mkdirSync(dir, { recursive: true });
|
|
270
|
+
const a = { attemptedAt: now.toISOString(), baseUrl, release: info?.release ?? 'unknown', abapVersion: info?.abapVersion ?? 'unknown' };
|
|
271
|
+
writeFileSync(cacheFile(alias, 'profile-attempt'), JSON.stringify(a, null, 2), 'utf8');
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
// best effort — at worst the next start tries the build again
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function clearAttempt(alias) {
|
|
278
|
+
try {
|
|
279
|
+
rmSync(cacheFile(alias, 'profile-attempt'), { force: true });
|
|
280
|
+
}
|
|
281
|
+
catch { /* best effort */ }
|
|
282
|
+
}
|
|
283
|
+
function readDiskCache(alias, now) {
|
|
284
|
+
try {
|
|
285
|
+
const path = cacheFile(alias);
|
|
286
|
+
if (!existsSync(path))
|
|
287
|
+
return null;
|
|
288
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
289
|
+
if (raw.cacheVersion !== CACHE_VERSION || !raw.builtAt)
|
|
290
|
+
return null;
|
|
291
|
+
if (now.getTime() - new Date(raw.builtAt).getTime() > CACHE_TTL_MS)
|
|
292
|
+
return null;
|
|
293
|
+
const { cacheVersion: _v, ...profile } = raw;
|
|
294
|
+
return profile;
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function writeDiskCache(alias, profile) {
|
|
301
|
+
try {
|
|
302
|
+
const dir = join(cspeachRoot(), 'sap-cache');
|
|
303
|
+
if (!existsSync(dir))
|
|
304
|
+
mkdirSync(dir, { recursive: true });
|
|
305
|
+
writeFileSync(cacheFile(alias), JSON.stringify({ ...profile, cacheVersion: CACHE_VERSION }, null, 2), 'utf8');
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// best effort — the memory result is still returned
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/** The one-line summary for <session_context>; '' when there is no profile. */
|
|
312
|
+
export function renderSapProfileBlock(p) {
|
|
313
|
+
if (!p)
|
|
314
|
+
return '';
|
|
315
|
+
const answer = (id) => supports(p, id).answer;
|
|
316
|
+
const viewEntity = answer('cds.viewEntity');
|
|
317
|
+
const cdsSyntax = viewEntity === 'yes' ? 'define view entity' : viewEntity === 'no' ? 'define view' : 'unknown';
|
|
318
|
+
const notAvailable = FEATURE_IDS
|
|
319
|
+
.map((id) => ({ id, v: supports(p, id) }))
|
|
320
|
+
.filter((x) => x.v.answer === 'no')
|
|
321
|
+
.map((x) => `${FEATURES[x.id].label}: ${x.v.reason}`)
|
|
322
|
+
.join('; ') || 'none known';
|
|
323
|
+
const attrs = [
|
|
324
|
+
`release="${escapeAttr(p.release)}"`,
|
|
325
|
+
`sp="${p.sp === null ? 'unknown' : `SP${String(p.sp).padStart(2, '0')}`}"`,
|
|
326
|
+
`abap="${escapeAttr(p.abapVersion)}"`,
|
|
327
|
+
`cloud="${p.cloud}"`,
|
|
328
|
+
`cds_view_syntax="${cdsSyntax}"`,
|
|
329
|
+
`rap="${answer('rap.bdef')}"`,
|
|
330
|
+
`rap_generator="${answer('generator.repository')}"`,
|
|
331
|
+
`table_entity="${answer('cds.tableEntity')}"`,
|
|
332
|
+
`discovery="${p.discovery.read ? 'read' : 'not read'}"`,
|
|
333
|
+
`built="${p.builtAt.slice(0, 10)}"`,
|
|
334
|
+
`not_available="${escapeAttr(notAvailable)}"`,
|
|
335
|
+
];
|
|
336
|
+
return `<sap_profile ${attrs.join(' ')} />`;
|
|
337
|
+
}
|
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
|
@@ -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.
|
|
@@ -184,6 +185,17 @@ registerTool({
|
|
|
184
185
|
is_error: true,
|
|
185
186
|
};
|
|
186
187
|
}
|
|
188
|
+
// Part 2a D12 — never draw a card for a write this release cannot take.
|
|
189
|
+
if (ctx.systemProfile) {
|
|
190
|
+
const refused = await approvalRefusedByProfile(args.changes ?? [], ctx);
|
|
191
|
+
if (refused.length > 0) {
|
|
192
|
+
return {
|
|
193
|
+
content: JSON.stringify({ error: NOT_ON_THIS_RELEASE, approved: false, changes: refused,
|
|
194
|
+
message: `This system cannot take ${refused.length === 1 ? 'this change' : 'these changes'}: ${refused.map((r) => `${r.type} ${r.object} — ${r.message}`).join('; ')}` }),
|
|
195
|
+
is_error: true,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
187
199
|
let cfg = await loadConfig();
|
|
188
200
|
// Task 2 (ux-wave1) — the approval flow reads the EFFECTIVE write mode:
|
|
189
201
|
// config overlaid by the session override, clamped to the active alias's
|