@cspeach/cli 1.1.34 → 1.1.35
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 +121 -31
- package/dist/lib/graceful-exit.js +17 -0
- package/dist/projects/answer-blockers.js +25 -12
- package/dist/renderer/change-card.js +28 -4
- package/dist/renderer/notices.js +27 -13
- package/dist/renderer/progress-chatter.js +3 -1
- package/dist/renderer/tool-labels.js +8 -2
- package/dist/renderer/verify-chain.js +41 -18
- package/dist/renderer/wrap-rows.js +87 -0
- package/dist/repl.js +6 -0
- package/dist/sap/connection-manager.js +15 -0
- package/dist/sap/session-end.js +66 -0
- package/dist/tools/ask-question.js +1 -1
- package/dist/tools/sap-read.js +22 -22
- package/dist/tools/sap-write.js +249 -18
- package/dist/tools/snapshot.js +26 -0
- package/dist/ui/app.js +19 -9
- package/dist/ui/approval-modal.js +37 -5
- package/dist/ui/live-area.js +14 -1
- package/dist/ui/widgets/ask-card.js +183 -107
- package/dist/ui/widgets/coaching-picker.js +22 -1
- package/dist/ui/widgets/confirm-card.js +102 -76
- package/package.json +2 -2
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// src/renderer/wrap-rows.ts
|
|
2
|
+
//
|
|
3
|
+
// The one wrap helper for card content (fix batch 2026-10-12, Task 2).
|
|
4
|
+
//
|
|
5
|
+
// THE RULE: card content wraps; chrome may cut. Text a person reads to make
|
|
6
|
+
// a decision — card titles, facts, option labels and descriptions, risk and
|
|
7
|
+
// failure reasons, SAP error details — is pre-wrapped here into a KNOWN
|
|
8
|
+
// number of rows. Ink never soft-wraps a live-frame row (its erase
|
|
9
|
+
// accounting counts rows), so callers draw each returned row as its own
|
|
10
|
+
// truncate-end <Text> and count every row in their height estimate. Status
|
|
11
|
+
// rows, hints, palettes, spinners, traces and "… N more" counters stay one
|
|
12
|
+
// row and cut with truncateToWidth.
|
|
13
|
+
//
|
|
14
|
+
// Replaces factRows (confirm-card) and wrapCardText (ask-card), which did the
|
|
15
|
+
// same job twice.
|
|
16
|
+
import stringWidth from 'string-width';
|
|
17
|
+
import { truncateToWidth } from './ui-width.js';
|
|
18
|
+
import { sanitizeControlChars } from './sanitize.js';
|
|
19
|
+
const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
20
|
+
/** The longest grapheme-whole prefix of `word` that fits `cells` (at least one grapheme). */
|
|
21
|
+
function takeCells(word, cells) {
|
|
22
|
+
let out = '';
|
|
23
|
+
let used = 0;
|
|
24
|
+
for (const { segment } of GRAPHEMES.segment(word)) {
|
|
25
|
+
const w = stringWidth(segment);
|
|
26
|
+
if (used + w > cells && out.length > 0)
|
|
27
|
+
break;
|
|
28
|
+
out += segment;
|
|
29
|
+
used += w;
|
|
30
|
+
if (used >= cells)
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* `text` as the rows it is drawn on, each at most `cells` terminal cells.
|
|
37
|
+
* Control characters are neutralised, tabs become two spaces and newlines
|
|
38
|
+
* one space. Text that fits is one row, unchanged. Otherwise it wraps at
|
|
39
|
+
* spaces (a word wider than the row is cut where it must be; wide characters
|
|
40
|
+
* count two cells and are never split) onto at most `maxRows` rows; when
|
|
41
|
+
* more is left, the last row ends with the `more` glyph (… / ...). With
|
|
42
|
+
* `maxRows` 1 this is truncateToWidth: one row, cut.
|
|
43
|
+
*/
|
|
44
|
+
export function wrapRows(text, cells, maxRows = 1) {
|
|
45
|
+
const flat = sanitizeControlChars(text).replace(/\t/g, ' ').replace(/\r?\n/g, ' ');
|
|
46
|
+
const width = Math.max(1, cells);
|
|
47
|
+
const max = Math.max(1, Math.floor(maxRows));
|
|
48
|
+
if (stringWidth(flat) <= width)
|
|
49
|
+
return [flat];
|
|
50
|
+
if (max === 1)
|
|
51
|
+
return [truncateToWidth(flat, width)];
|
|
52
|
+
const indent = /^ */.exec(flat)[0].slice(0, Math.max(0, width - 1));
|
|
53
|
+
const rows = [];
|
|
54
|
+
let cur = indent;
|
|
55
|
+
const curEmpty = () => cur.length === 0 || cur === indent;
|
|
56
|
+
for (let word of flat.trim().split(/ +/).filter((w) => w.length > 0)) {
|
|
57
|
+
while (stringWidth(word) > width) {
|
|
58
|
+
if (!curEmpty()) {
|
|
59
|
+
rows.push(cur);
|
|
60
|
+
cur = '';
|
|
61
|
+
}
|
|
62
|
+
const head = takeCells(word, width - stringWidth(cur));
|
|
63
|
+
rows.push(cur + head);
|
|
64
|
+
cur = '';
|
|
65
|
+
word = word.slice(head.length);
|
|
66
|
+
}
|
|
67
|
+
if (word.length === 0)
|
|
68
|
+
continue;
|
|
69
|
+
if (curEmpty())
|
|
70
|
+
cur += word;
|
|
71
|
+
else if (stringWidth(cur) + 1 + stringWidth(word) <= width)
|
|
72
|
+
cur += ` ${word}`;
|
|
73
|
+
else {
|
|
74
|
+
rows.push(cur);
|
|
75
|
+
cur = word;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!curEmpty() || rows.length === 0)
|
|
79
|
+
rows.push(cur);
|
|
80
|
+
if (rows.length <= max)
|
|
81
|
+
return rows;
|
|
82
|
+
const kept = rows.slice(0, max);
|
|
83
|
+
// The trailing space guarantees a cut (and so the more glyph) even when the
|
|
84
|
+
// joined rest would happen to fit.
|
|
85
|
+
kept[max - 1] = truncateToWidth(`${rows.slice(max - 1).join(' ')} `, width);
|
|
86
|
+
return kept;
|
|
87
|
+
}
|
package/dist/repl.js
CHANGED
|
@@ -144,6 +144,7 @@ import { maybeExpandNumberSelection, lastAssistantText } from './repl/numbered-m
|
|
|
144
144
|
import { attachEarlyLineBuffer, noopEarlyLineBuffer } from './repl/early-line-buffer.js';
|
|
145
145
|
import { createRestoreGuard } from './repl/restore-guard.js';
|
|
146
146
|
import { gracefulExit } from './lib/graceful-exit.js';
|
|
147
|
+
import { endSapSessionAfterTurn } from './sap/session-end.js';
|
|
147
148
|
import { snapshotTurnStart, pushPostTurnStatus, seedFooterSpend } from './repl/post-turn-status.js';
|
|
148
149
|
import { looksLikeCspeachShellCommand, CSPEACH_SHELL_NUDGE } from './repl/cspeach-shell-detect.js';
|
|
149
150
|
import { applyLocalBuildConfig, applyStandaloneFileTools } from './tools/local-build.js';
|
|
@@ -2118,6 +2119,9 @@ export async function runRepl(opts) {
|
|
|
2118
2119
|
planSubagentDispatches = getAndResetPlanPhaseSubagentDispatches();
|
|
2119
2120
|
}
|
|
2120
2121
|
}
|
|
2122
|
+
// 2026-10-13 — end the stateful SAP session between turns, so a lock
|
|
2123
|
+
// a failed write left behind does not outlive the turn (SM12).
|
|
2124
|
+
await endSapSessionAfterTurn(adt);
|
|
2121
2125
|
}
|
|
2122
2126
|
// B3 — persist the plan revision from the resume turn's manifest
|
|
2123
2127
|
// block (runs even after a turn error: partial assistant content is
|
|
@@ -4117,6 +4121,8 @@ export async function runRepl(opts) {
|
|
|
4117
4121
|
planSubagentDispatches = getAndResetPlanPhaseSubagentDispatches();
|
|
4118
4122
|
}
|
|
4119
4123
|
}
|
|
4124
|
+
// 2026-10-13 — end the stateful SAP session between turns (Ink mirror).
|
|
4125
|
+
await endSapSessionAfterTurn(adt);
|
|
4120
4126
|
}
|
|
4121
4127
|
// B3 — persist the plan revision from the resume turn's manifest block.
|
|
4122
4128
|
// FALLBACK: skipped when the mid-turn onPlanManifest hook already saved it
|
|
@@ -3,8 +3,22 @@ import { SapConnection } from '@cspeach/sap-client';
|
|
|
3
3
|
import { resolveSapTls } from '../config/loader.js';
|
|
4
4
|
import { password as promptPassword } from '@inquirer/prompts';
|
|
5
5
|
import { isHeadless } from '../renderer/tty.js';
|
|
6
|
+
import { registerExitTask } from '../lib/graceful-exit.js';
|
|
7
|
+
import { endSapSessions } from './session-end.js';
|
|
6
8
|
const SAP_SERVICE = 'cspeach-sap';
|
|
7
9
|
const cache = new Map();
|
|
10
|
+
let exitTaskRegistered = false;
|
|
11
|
+
/**
|
|
12
|
+
* 2026-10-13 — on exit, end the stateful SAP session of every connection this
|
|
13
|
+
* process opened, so locks left in it leave SM12 now instead of when SAP times
|
|
14
|
+
* the session out. Registered once, on the first connection.
|
|
15
|
+
*/
|
|
16
|
+
function registerSessionEndOnExit() {
|
|
17
|
+
if (exitTaskRegistered)
|
|
18
|
+
return;
|
|
19
|
+
exitTaskRegistered = true;
|
|
20
|
+
registerExitTask(() => endSapSessions(cache.values()));
|
|
21
|
+
}
|
|
8
22
|
/**
|
|
9
23
|
* The single place that reads an SAP alias password: keychain first, then a
|
|
10
24
|
* masked prompt when interactive. Headless callers (and the SAP link, which
|
|
@@ -69,6 +83,7 @@ export async function getConnection(alias, cfg) {
|
|
|
69
83
|
// our existing cookies belong to a different one.
|
|
70
84
|
await conn.login();
|
|
71
85
|
cache.set(alias, conn);
|
|
86
|
+
registerSessionEndOnExit();
|
|
72
87
|
return conn;
|
|
73
88
|
}
|
|
74
89
|
export function clearConnectionCache() {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ending CSPeach's stateful SAP session (live finding 2026-10-13).
|
|
3
|
+
*
|
|
4
|
+
* Lock chains open a stateful ADT session on SAP. Nothing ended it, so any
|
|
5
|
+
* lock left inside it — after a failed write, or from a unit-test run — stayed
|
|
6
|
+
* in SM12 until SAP timed the session out, even after the CLI exited.
|
|
7
|
+
*
|
|
8
|
+
* When it is ended:
|
|
9
|
+
* - after every TOP-LEVEL turn (the REPL), so a lock left by a failed write
|
|
10
|
+
* never outlives the turn — except while a manual `sap_lock` is held,
|
|
11
|
+
* because ending the session would silently drop that lock;
|
|
12
|
+
* - on exit, for every connection, always (the process is going away).
|
|
13
|
+
*
|
|
14
|
+
* Both are capped so a slow or unreachable SAP never holds up the prompt or
|
|
15
|
+
* the exit, and neither ever throws. The next lock chain simply opens a fresh
|
|
16
|
+
* session (sap-client forgets the context cookie when it ends one).
|
|
17
|
+
*/
|
|
18
|
+
/** Longest we wait for SAP to confirm the session ended. */
|
|
19
|
+
export const END_SESSION_CAP_MS = 3_000;
|
|
20
|
+
let manualLocks = 0;
|
|
21
|
+
/** A `sap_lock` call returned a handle. */
|
|
22
|
+
export function noteManualLock() {
|
|
23
|
+
manualLocks += 1;
|
|
24
|
+
}
|
|
25
|
+
/** A `sap_unlock` call ran. */
|
|
26
|
+
export function noteManualUnlock() {
|
|
27
|
+
manualLocks = Math.max(0, manualLocks - 1);
|
|
28
|
+
}
|
|
29
|
+
export function __resetManualLocksForTests() {
|
|
30
|
+
manualLocks = 0;
|
|
31
|
+
}
|
|
32
|
+
function capped(p, onTimeout) {
|
|
33
|
+
let timer;
|
|
34
|
+
return Promise.race([
|
|
35
|
+
p,
|
|
36
|
+
new Promise((r) => { timer = setTimeout(() => r(onTimeout), END_SESSION_CAP_MS); }),
|
|
37
|
+
]).finally(() => { if (timer)
|
|
38
|
+
clearTimeout(timer); });
|
|
39
|
+
}
|
|
40
|
+
/** After a top-level turn: end the session unless a manual lock is still held. */
|
|
41
|
+
export async function endSapSessionAfterTurn(client) {
|
|
42
|
+
// Duck-typed: the offline client and an older sap-client have no endSession.
|
|
43
|
+
const adt = client;
|
|
44
|
+
if (!adt || typeof adt.endSession !== 'function')
|
|
45
|
+
return { ended: false, skipped: 'no_client' };
|
|
46
|
+
if (manualLocks > 0)
|
|
47
|
+
return { ended: false, skipped: 'manual_lock' };
|
|
48
|
+
try {
|
|
49
|
+
return await capped(adt.endSession(), { ended: false, error: 'timeout' });
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
return { ended: false, error: e instanceof Error ? e.message : String(e) };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** On exit: end the session of every connection, capped as a whole. */
|
|
56
|
+
export async function endSapSessions(conns) {
|
|
57
|
+
const all = [...conns].map(async (c) => {
|
|
58
|
+
if (typeof c.endStatefulSession !== 'function')
|
|
59
|
+
return;
|
|
60
|
+
try {
|
|
61
|
+
await c.endStatefulSession();
|
|
62
|
+
}
|
|
63
|
+
catch { /* best effort on the way out */ }
|
|
64
|
+
});
|
|
65
|
+
await capped(Promise.all(all).then(() => undefined), undefined);
|
|
66
|
+
}
|
|
@@ -251,7 +251,7 @@ registerTool({
|
|
|
251
251
|
},
|
|
252
252
|
multiSelect: {
|
|
253
253
|
type: 'boolean',
|
|
254
|
-
description: 'true = the user may pick several options; false/omitted = pick exactly one.',
|
|
254
|
+
description: 'true = the user may pick several options; false/omitted = pick exactly one. When the user ticks options and also types an answer, it comes back as free text (custom: true): the ticked labels, then the typed text, comma-separated.',
|
|
255
255
|
},
|
|
256
256
|
options: {
|
|
257
257
|
type: 'array',
|
package/dist/tools/sap-read.js
CHANGED
|
@@ -644,29 +644,29 @@ registerTool({
|
|
|
644
644
|
handler: async (args, ctx) => {
|
|
645
645
|
// runUnitTest(type, name) — type first
|
|
646
646
|
const result = await ctx.adt.runUnitTest(args.type, args.name);
|
|
647
|
-
// B2 false-green guard
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
// a
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
const summary = result
|
|
657
|
-
|
|
658
|
-
const
|
|
659
|
-
|
|
647
|
+
// B2 false-green guard: a run in which no test actually ran reads to a
|
|
648
|
+
// caller like "nothing failed → green". Wrap the (still-carried) raw result
|
|
649
|
+
// in an explicit UNVERIFIED envelope when:
|
|
650
|
+
// - total is 0, missing or not a number — even when programs are present
|
|
651
|
+
// (a class with no methods, or only tolerable class alerts; review I1);
|
|
652
|
+
// - every test was skipped (total > 0, passed 0, failed 0; review M1).
|
|
653
|
+
// It is no longer blamed on an ADT gap: the 2026-10-12 sap-client fix sends
|
|
654
|
+
// the request shape Eclipse sends. A run with a pass or a failure passes
|
|
655
|
+
// through unchanged.
|
|
656
|
+
const summary = result
|
|
657
|
+
?.summary;
|
|
658
|
+
const total = summary?.total;
|
|
659
|
+
const noTestsRun = typeof total !== 'number' || !Number.isFinite(total) || total === 0;
|
|
660
|
+
const allSkipped = !noTestsRun && summary?.passed === 0 && summary?.failed === 0;
|
|
661
|
+
if (noTestsRun || allSkipped) {
|
|
662
|
+
const n = total;
|
|
663
|
+
const warning = noTestsRun
|
|
664
|
+
? '0 tests ran — this is not a pass. Check that the object has an active test ' +
|
|
665
|
+
'class (FOR TESTING) with active test methods, then run it again.'
|
|
666
|
+
: `${n === 1 ? '1 test was' : `${n} tests were`} skipped and none ran — this is not ` +
|
|
667
|
+
'a pass. Check the risk level and duration of the test classes, then run it again.';
|
|
660
668
|
return {
|
|
661
|
-
content: JSON.stringify({
|
|
662
|
-
verification: 'UNVERIFIED',
|
|
663
|
-
warning: 'ABAP Unit run enumerated 0 tests (empty runResult). This does NOT mean the tests ' +
|
|
664
|
-
'passed — it means none executed. A FOR TESTING class that is active + syntax-clean ' +
|
|
665
|
-
'still returning 0 is a known ADT AUnit-run gap (project_aunit_run_gap): the ' +
|
|
666
|
-
'authoritative check is Eclipse "Run As → ABAP Unit" (Ctrl+Shift+F10). Do NOT treat ' +
|
|
667
|
-
'this as a green test gate.',
|
|
668
|
-
result,
|
|
669
|
-
}, null, 2),
|
|
669
|
+
content: JSON.stringify({ verification: 'UNVERIFIED', warning, result }, null, 2),
|
|
670
670
|
};
|
|
671
671
|
}
|
|
672
672
|
return { content: JSON.stringify(result, null, 2) };
|
package/dist/tools/sap-write.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* unlock(type, name, lockHandle) → Promise<void> line 1949
|
|
17
17
|
* serviceBindingPublish(name) → Promise<{status,error?}> line 2658
|
|
18
18
|
* serviceBindingUnpublish(name) → Promise<{status,error?}>
|
|
19
|
-
*
|
|
19
|
+
* maintainMessages({name, messages, transport?}) → Promise<MaintainMessagesResult> (0.1.19)
|
|
20
20
|
* numberRangeIntervals (STUB — not yet in sap-client)
|
|
21
21
|
*
|
|
22
22
|
* auditLog aliased from safety/audit-log.ts#log via sap-client/src/index.ts.
|
|
@@ -26,7 +26,7 @@ import { readFileSync } from 'node:fs';
|
|
|
26
26
|
import { registerTool } from './index.js';
|
|
27
27
|
import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
|
|
28
28
|
import { grantClaimMismatch, grantActivateMismatch, grantOwningTransportMismatch, grantOwningTransportDetail } from '../approvals/grant-claims.js';
|
|
29
|
-
import { autoSnapshot, autoSnapshotClassInclude } from './snapshot.js';
|
|
29
|
+
import { autoSnapshot, autoSnapshotClassInclude, autoSnapshotMessageClass } from './snapshot.js';
|
|
30
30
|
import { includeSnapshotType } from './include-snapshot.js';
|
|
31
31
|
import { buildWriteChainLine } from '../renderer/verify-chain.js';
|
|
32
32
|
import { buildChangeCard } from '../renderer/change-card.js';
|
|
@@ -49,6 +49,7 @@ import { noteWriteTransport, setCurrentTransport } from '../repl/current-transpo
|
|
|
49
49
|
import { markTransportCreatedThisSession } from './transport.js';
|
|
50
50
|
import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
|
|
51
51
|
import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
|
|
52
|
+
import { noteManualLock, noteManualUnlock } from '../sap/session-end.js';
|
|
52
53
|
// ── Transcript visibility helpers (D16-display + tour slide-7, 2026-06-12) ──
|
|
53
54
|
//
|
|
54
55
|
// Rule 7 auto-snapshots always fired but the transcript never said so
|
|
@@ -868,7 +869,11 @@ registerTool({
|
|
|
868
869
|
object: result.objectName,
|
|
869
870
|
type: result.objectType,
|
|
870
871
|
package: result.packageName,
|
|
871
|
-
|
|
872
|
+
// A message class has no source and is never activated: its messages
|
|
873
|
+
// go in through sap_message_maintain (bug 3, 2026-10-12).
|
|
874
|
+
next_step: String(result.objectType ?? '').toUpperCase() === 'MSAG'
|
|
875
|
+
? 'add_messages_via_sap_message_maintain (a message class has no source and no activation)'
|
|
876
|
+
: 'write_source_via_sap_set_source_then_activate',
|
|
872
877
|
}),
|
|
873
878
|
};
|
|
874
879
|
},
|
|
@@ -1573,6 +1578,9 @@ registerTool({
|
|
|
1573
1578
|
},
|
|
1574
1579
|
handler: async (args, ctx) => {
|
|
1575
1580
|
const result = await ctx.adt.lock(args.type, args.name);
|
|
1581
|
+
// The turn-end session close keeps the session while this lock is held.
|
|
1582
|
+
if (result?.status === 'locked')
|
|
1583
|
+
noteManualLock();
|
|
1576
1584
|
return { content: JSON.stringify(result) };
|
|
1577
1585
|
},
|
|
1578
1586
|
});
|
|
@@ -1596,6 +1604,7 @@ registerTool({
|
|
|
1596
1604
|
},
|
|
1597
1605
|
handler: async (args, ctx) => {
|
|
1598
1606
|
await ctx.adt.unlock(args.type, args.name, args.lockHandle);
|
|
1607
|
+
noteManualUnlock();
|
|
1599
1608
|
return { content: JSON.stringify({ status: 'ok' }) };
|
|
1600
1609
|
},
|
|
1601
1610
|
});
|
|
@@ -1644,34 +1653,256 @@ registerTool({
|
|
|
1644
1653
|
return { content: JSON.stringify(result) };
|
|
1645
1654
|
},
|
|
1646
1655
|
});
|
|
1647
|
-
// ── sap_message_maintain
|
|
1656
|
+
// ── sap_message_maintain ──────────────────────────────────────────────────────
|
|
1648
1657
|
//
|
|
1649
|
-
//
|
|
1650
|
-
//
|
|
1658
|
+
// Signature (sap-client 0.1.19): maintainMessages({name, messages, transport?})
|
|
1659
|
+
// → Promise<MaintainMessagesResult>
|
|
1660
|
+
// Adds or changes T100 messages in an existing message class, in one batch.
|
|
1661
|
+
// sap-client merges into the existing rows, locks, PUTs the whole class,
|
|
1662
|
+
// unlocks in a finally and READS BACK: success is `verified`, never HTTP 200.
|
|
1663
|
+
// Rule 7 snapshot = the class document (all messages) before the write.
|
|
1664
|
+
// No syntax check and no activation: a message class is not activatable.
|
|
1665
|
+
const MESSAGE_TEXT_MAX = 73;
|
|
1666
|
+
/** Same rule as sap-client's isValidMessageClassName (optional /NAMESPACE/). */
|
|
1667
|
+
const MESSAGE_CLASS_NAME_RE = /^(?:\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,20}$/;
|
|
1668
|
+
/** Review M-1: a plain sentence when SAP may still hold a lock, else undefined. */
|
|
1669
|
+
function lockWarning(className, r) {
|
|
1670
|
+
if (r.released !== false)
|
|
1671
|
+
return undefined;
|
|
1672
|
+
const what = (r.unlockFailures ?? []).join(', ') || 'unlock';
|
|
1673
|
+
return `SAP may still hold a lock on message class ${className} (failed: ${what}). Check SM12 and remove it there, or it clears when the session ends.`;
|
|
1674
|
+
}
|
|
1675
|
+
/** The rows from the tool args (`messages`, or the single-message short form). */
|
|
1676
|
+
function messageRowsFromArgs(args) {
|
|
1677
|
+
const list = Array.isArray(args['messages']) ? args['messages'] : [];
|
|
1678
|
+
const rows = list.map((m) => ({
|
|
1679
|
+
msgno: String(m?.['msgno'] ?? m?.['number'] ?? '').trim(),
|
|
1680
|
+
text: typeof m?.['text'] === 'string' ? m['text'] : '',
|
|
1681
|
+
...(typeof m?.['selfExplanatory'] === 'boolean' ? { selfExplanatory: m['selfExplanatory'] } : {}),
|
|
1682
|
+
}));
|
|
1683
|
+
if (rows.length === 0 && args['messageNumber'] !== undefined) {
|
|
1684
|
+
rows.push({
|
|
1685
|
+
msgno: String(args['messageNumber']).trim(),
|
|
1686
|
+
text: typeof args['messageText'] === 'string' ? args['messageText'] : '',
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
return rows;
|
|
1690
|
+
}
|
|
1691
|
+
/** Same rules as sap-client's validateMessageUpserts — checked here so a bad
|
|
1692
|
+
* request never spends the approval. A plain sentence, or null. */
|
|
1693
|
+
function messageRowsProblem(rows) {
|
|
1694
|
+
if (rows.length === 0)
|
|
1695
|
+
return 'Give at least one message to add or change.';
|
|
1696
|
+
const seen = new Set();
|
|
1697
|
+
for (const r of rows) {
|
|
1698
|
+
if (!/^\d{3}$/.test(r.msgno))
|
|
1699
|
+
return `Message number "${r.msgno}" is not valid. Use three digits, 000 to 999.`;
|
|
1700
|
+
if (seen.has(r.msgno))
|
|
1701
|
+
return `Message ${r.msgno} is in the request more than once.`;
|
|
1702
|
+
seen.add(r.msgno);
|
|
1703
|
+
if (r.text.trim().length === 0)
|
|
1704
|
+
return `Message ${r.msgno} has an empty text.`;
|
|
1705
|
+
const len = Array.from(r.text).length;
|
|
1706
|
+
if (len > MESSAGE_TEXT_MAX)
|
|
1707
|
+
return `Message ${r.msgno} is ${len} characters long. SAP allows ${MESSAGE_TEXT_MAX}.`;
|
|
1708
|
+
}
|
|
1709
|
+
return null;
|
|
1710
|
+
}
|
|
1651
1711
|
registerTool({
|
|
1652
1712
|
name: 'sap_message_maintain',
|
|
1653
|
-
description: '
|
|
1654
|
-
+ '
|
|
1655
|
-
+ '
|
|
1713
|
+
description: 'Add or change messages in an existing message class (MSAG, the T100 texts behind SE91). '
|
|
1714
|
+
+ 'Create the class first with sap_create_object type MSAG. One call takes many messages. '
|
|
1715
|
+
+ 'Each message: msgno (three digits, 000-999) and text (up to 73 characters, &1-&4 placeholders allowed). '
|
|
1716
|
+
+ 'Messages not in the call are kept. No source, no syntax check, no activation. '
|
|
1717
|
+
+ 'The type (E, W, I, S) is not stored: it is chosen where the message is raised, e.g. MESSAGE e001(zfoo). '
|
|
1718
|
+
+ 'Requires approval_id.',
|
|
1656
1719
|
isMutating: true,
|
|
1657
1720
|
requiresSap: true,
|
|
1658
1721
|
input_schema: {
|
|
1659
1722
|
type: 'object',
|
|
1660
1723
|
properties: {
|
|
1661
|
-
messageClass: { type: 'string', description: 'Message class name (e.g.
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1724
|
+
messageClass: { type: 'string', description: 'Message class name (e.g. ZSALES_MSG)' },
|
|
1725
|
+
messages: {
|
|
1726
|
+
type: 'array',
|
|
1727
|
+
description: 'Messages to add or change',
|
|
1728
|
+
items: {
|
|
1729
|
+
type: 'object',
|
|
1730
|
+
properties: {
|
|
1731
|
+
msgno: { type: 'string', description: 'Three-digit message number (e.g. 001)' },
|
|
1732
|
+
text: { type: 'string', description: 'Message text, up to 73 characters (e.g. "Order &1 not found")' },
|
|
1733
|
+
selfExplanatory: { type: 'boolean', description: 'Optional. New messages default to true (no long text).' },
|
|
1734
|
+
},
|
|
1735
|
+
required: ['msgno', 'text'],
|
|
1736
|
+
},
|
|
1737
|
+
},
|
|
1738
|
+
messageNumber: { type: 'string', description: 'Short form for one message: the number (use messages for several)' },
|
|
1739
|
+
messageText: { type: 'string', description: 'Short form for one message: the text' },
|
|
1740
|
+
transport: { type: 'string', description: 'Transport request number (optional)' },
|
|
1741
|
+
approval_id: { type: 'string', description: 'JWT from request_approval' },
|
|
1665
1742
|
},
|
|
1666
|
-
required: ['messageClass', '
|
|
1743
|
+
required: ['messageClass', 'approval_id'],
|
|
1667
1744
|
},
|
|
1668
|
-
handler: async (
|
|
1745
|
+
handler: async (args, ctx) => {
|
|
1746
|
+
const className = String(args.messageClass ?? '').trim().toUpperCase();
|
|
1747
|
+
// ── Input check — before the approval is spent ──────────────────────────
|
|
1748
|
+
const rows = messageRowsFromArgs(args);
|
|
1749
|
+
const problem = !className
|
|
1750
|
+
? 'Give the message class name.'
|
|
1751
|
+
: !MESSAGE_CLASS_NAME_RE.test(className)
|
|
1752
|
+
? `"${String(args.messageClass)}" is not a valid message class name (letters, digits and _ only, 20 characters at most).`
|
|
1753
|
+
: messageRowsProblem(rows);
|
|
1754
|
+
if (problem) {
|
|
1755
|
+
return { content: JSON.stringify({ error: 'invalid_messages', detail: problem }), is_error: true };
|
|
1756
|
+
}
|
|
1757
|
+
// ── Capability — before the approval is spent (review I-1) ──────────────
|
|
1758
|
+
const adt = ctx.adt;
|
|
1759
|
+
if (typeof adt.readMessageClass !== 'function' || typeof adt.maintainMessages !== 'function') {
|
|
1760
|
+
return {
|
|
1761
|
+
content: JSON.stringify({
|
|
1762
|
+
error: 'message_maintain_unavailable',
|
|
1763
|
+
detail: 'This CSPeach build cannot write messages yet. Update CSPeach and try again.',
|
|
1764
|
+
}),
|
|
1765
|
+
is_error: true,
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
const msagClient = adt;
|
|
1769
|
+
// ── Gate 1: Approval ────────────────────────────────────────────────────
|
|
1770
|
+
const verify = await verifyAndSpendApprovalId(args.approval_id, args.messageClass, 'modify');
|
|
1771
|
+
if (!verify.ok) {
|
|
1772
|
+
return {
|
|
1773
|
+
content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
|
|
1774
|
+
is_error: true,
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
1778
|
+
{
|
|
1779
|
+
const claim = grantClaimMismatch(verify.payload, { type: 'MSAG', transport: args.transport ?? ctx.currentTransport?.get() ?? null });
|
|
1780
|
+
if (claim)
|
|
1781
|
+
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
1782
|
+
}
|
|
1783
|
+
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
1784
|
+
const startedAt = Date.now();
|
|
1785
|
+
// ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
|
|
1786
|
+
{
|
|
1787
|
+
const sessionTransport = ctx.currentTransport?.get() ?? null;
|
|
1788
|
+
if (args.transport && sessionTransport && args.transport !== sessionTransport) {
|
|
1789
|
+
ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
const effectiveTransport = args.transport ?? ctx.currentTransport?.get() ?? undefined;
|
|
1793
|
+
const resolved = await resolveWriteTransport(ctx, 'MSAG', className, effectiveTransport);
|
|
1794
|
+
// 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
|
|
1795
|
+
if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
|
|
1796
|
+
return {
|
|
1797
|
+
content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(className, resolved.owning) }),
|
|
1798
|
+
is_error: true,
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
// ── Gate 2: Snapshot (Rule 7) — HARD gate, of the whole class ────────────
|
|
1802
|
+
let snapOutcome;
|
|
1803
|
+
try {
|
|
1804
|
+
snapOutcome = await autoSnapshotMessageClass(className, msagClient);
|
|
1805
|
+
}
|
|
1806
|
+
catch (err) {
|
|
1807
|
+
return {
|
|
1808
|
+
content: JSON.stringify({ error: ERR.SNAPSHOT_FAILED, detail: String(err) }),
|
|
1809
|
+
is_error: true,
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
if (snapOutcome.reason === 'object_not_found') {
|
|
1813
|
+
return {
|
|
1814
|
+
content: JSON.stringify({
|
|
1815
|
+
error: 'message_class_not_found',
|
|
1816
|
+
detail: `Message class ${className} does not exist. Create it first with sap_create_object type MSAG, then add the messages.`,
|
|
1817
|
+
}),
|
|
1818
|
+
is_error: true,
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
if (!snapOutcome.taken) {
|
|
1822
|
+
return {
|
|
1823
|
+
content: JSON.stringify({
|
|
1824
|
+
error: ERR.SNAPSHOT_FAILED,
|
|
1825
|
+
reason: snapOutcome.reason ?? 'source_read_failed',
|
|
1826
|
+
detail: snapOutcome.detail,
|
|
1827
|
+
recovery: 'The message class could not be read, so no snapshot was possible and nothing was written. If this is a connection or sign-in problem, fix it and try again.',
|
|
1828
|
+
}),
|
|
1829
|
+
is_error: true,
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
emitSnapshotNotice(ctx, snapOutcome, 'MSAG', className);
|
|
1833
|
+
// ── WAL: record pending entry ────────────────────────────────────────────
|
|
1834
|
+
await appendPending(ctx.session, {
|
|
1835
|
+
tool_use_id: toolUseId,
|
|
1836
|
+
tool: 'sap_message_maintain',
|
|
1837
|
+
args: { messageClass: className, messages: rows.map((r) => r.msgno) },
|
|
1838
|
+
started_at: new Date().toISOString(),
|
|
1839
|
+
});
|
|
1840
|
+
// ── Write (sap-client locks, PUTs, unlocks and reads back) ───────────────
|
|
1841
|
+
let result;
|
|
1842
|
+
try {
|
|
1843
|
+
result = await msagClient.maintainMessages({
|
|
1844
|
+
name: className,
|
|
1845
|
+
messages: rows,
|
|
1846
|
+
...(resolved.transport ? { transport: resolved.transport } : {}),
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
catch (err) {
|
|
1850
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1851
|
+
await finalizeToolCall(ctx.session, toolUseId, detail, true, Date.now() - startedAt);
|
|
1852
|
+
return {
|
|
1853
|
+
content: JSON.stringify({ error: 'message_maintain_failed', detail }),
|
|
1854
|
+
is_error: true,
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
const lockNote = lockWarning(className, result);
|
|
1858
|
+
if (lockNote)
|
|
1859
|
+
ctx.chunkEmitter?.emit('warn', lockNote);
|
|
1860
|
+
if (!result.verified) {
|
|
1861
|
+
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
|
|
1862
|
+
return {
|
|
1863
|
+
content: JSON.stringify({
|
|
1864
|
+
error: 'message_maintain_failed',
|
|
1865
|
+
...(lockNote ? { lock_warning: lockNote } : {}),
|
|
1866
|
+
detail: result.error ?? 'The messages could not be confirmed in SAP.',
|
|
1867
|
+
missing: result.missing,
|
|
1868
|
+
...(result.httpStatus !== undefined ? { http_status: result.httpStatus } : {}),
|
|
1869
|
+
...(result.lockedInTransport ? {
|
|
1870
|
+
locked_in_transport: result.lockedInTransport,
|
|
1871
|
+
recovery: `Retry this exact call with transport=${result.lockedInTransport} — that request already owns the message class.`,
|
|
1872
|
+
} : {}),
|
|
1873
|
+
}),
|
|
1874
|
+
is_error: true,
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
if (result.noChange) {
|
|
1878
|
+
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ unchanged: true }), false, Date.now() - startedAt);
|
|
1879
|
+
return {
|
|
1880
|
+
content: JSON.stringify({
|
|
1881
|
+
written: false,
|
|
1882
|
+
unchanged: true,
|
|
1883
|
+
message_class: className,
|
|
1884
|
+
changes: result.changes,
|
|
1885
|
+
message: `Message class ${className} already has these texts. Nothing was changed.`,
|
|
1886
|
+
}),
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
// Audit only after SAP's read-back confirmed every message.
|
|
1890
|
+
await auditLog('maintainMessages', 'MSAG', className, 'success', Date.now() - startedAt, result.changes.map((c) => `${c.msgno}:${c.action}`).join(' '));
|
|
1891
|
+
noteWriteTransport(resolved.transport); // F-L2-1 statusline wire
|
|
1892
|
+
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ written: true, verified: true, changes: result.changes }), false, Date.now() - startedAt);
|
|
1893
|
+
const written = result.changes.filter((c) => c.action !== 'unchanged').map((c) => c.msgno);
|
|
1894
|
+
ctx.chunkEmitter?.emit('info', `messages ✓ ${className} ${written.join(' ')} (no activation needed)`);
|
|
1895
|
+
const first = written[0] ?? rows[0].msgno;
|
|
1669
1896
|
return {
|
|
1670
1897
|
content: JSON.stringify({
|
|
1671
|
-
|
|
1672
|
-
|
|
1898
|
+
written: true,
|
|
1899
|
+
verified: true,
|
|
1900
|
+
message_class: className,
|
|
1901
|
+
changes: result.changes,
|
|
1902
|
+
...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
|
|
1903
|
+
...(lockNote ? { lock_warning: lockNote } : {}),
|
|
1904
|
+
next_step: `Done — no activation needed. Raise these from code by number, e.g. MESSAGE e${first}(${className.toLowerCase()}) WITH …, or a T100 textid in an exception class. Never hard-code the texts.`,
|
|
1673
1905
|
}),
|
|
1674
|
-
is_error: true,
|
|
1675
1906
|
};
|
|
1676
1907
|
},
|
|
1677
1908
|
});
|
package/dist/tools/snapshot.js
CHANGED
|
@@ -127,6 +127,32 @@ export async function autoSnapshotClassInclude(className, includeType, adt) {
|
|
|
127
127
|
const entry = await snapshots.take(includeSnapshotType(includeType), className, src, 'before_write');
|
|
128
128
|
return { taken: true, entry };
|
|
129
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Rule-7 snapshot of a message class, taken before sap_message_maintain
|
|
132
|
+
* changes it. A message class has no source: the snapshot is the whole class
|
|
133
|
+
* document as SAP serves it (every message), stored under type MSAG.
|
|
134
|
+
*
|
|
135
|
+
* 404 → 'object_not_found' (no such class — the write cannot go ahead)
|
|
136
|
+
* other read error → 'source_read_failed' (+ detail) — the write is blocked
|
|
137
|
+
* read OK → snapshot taken
|
|
138
|
+
* A snapshots.take failure THROWS, like autoSnapshot.
|
|
139
|
+
*/
|
|
140
|
+
export async function autoSnapshotMessageClass(name, adt) {
|
|
141
|
+
let xml;
|
|
142
|
+
try {
|
|
143
|
+
xml = (await adt.readMessageClass(name)).xml;
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
if (isNotFound(err))
|
|
147
|
+
return { taken: false, reason: 'object_not_found' };
|
|
148
|
+
return { taken: false, reason: 'source_read_failed', detail: shortErrorMessage(err) };
|
|
149
|
+
}
|
|
150
|
+
if (typeof xml !== 'string' || xml.length === 0) {
|
|
151
|
+
return { taken: false, reason: 'source_read_failed', detail: 'the message class read returned no document' };
|
|
152
|
+
}
|
|
153
|
+
const entry = await snapshots.take('MSAG', name.toUpperCase(), xml, 'before_write');
|
|
154
|
+
return { taken: true, entry };
|
|
155
|
+
}
|
|
130
156
|
// ── sap_snapshot_list ─────────────────────────────────────────────────────────
|
|
131
157
|
registerTool({
|
|
132
158
|
name: 'sap_snapshot_list',
|