@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/tools/sap-write.js
CHANGED
|
@@ -22,156 +22,82 @@
|
|
|
22
22
|
* auditLog aliased from safety/audit-log.ts#log via sap-client/src/index.ts.
|
|
23
23
|
*/
|
|
24
24
|
import crypto from 'node:crypto';
|
|
25
|
-
import { readFileSync } from 'node:fs';
|
|
26
25
|
import { registerTool } from './index.js';
|
|
26
|
+
import { byObjectType } from '../sap/profile-gate.js';
|
|
27
27
|
import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
|
|
28
|
-
import {
|
|
29
|
-
import { autoSnapshot, autoSnapshotClassInclude, autoSnapshotMessageClass } from './snapshot.js';
|
|
30
|
-
import { includeSnapshotType } from './include-snapshot.js';
|
|
31
|
-
import { buildWriteChainLine } from '../renderer/verify-chain.js';
|
|
32
|
-
import { buildChangeCard } from '../renderer/change-card.js';
|
|
28
|
+
import { grantActivateMismatch } from '../approvals/grant-claims.js';
|
|
33
29
|
import { getLook } from '../renderer/look.js';
|
|
34
|
-
import {
|
|
35
|
-
import { verifySyntax, verifyActive } from './verify.js';
|
|
30
|
+
import { verifyActive } from './verify.js';
|
|
36
31
|
import { hasRecordedSyntaxErrorsByName, clearSyntaxErrorsAfterActivation } from './syntax-state.js';
|
|
37
32
|
import { teamFixOffers, cspeachWritesThisSession } from '../register/offer.js';
|
|
38
|
-
import { isJourneyActive } from '../journey/flow.js';
|
|
39
33
|
import { appendPending, finalizeToolCall } from '../session/pending.js';
|
|
40
34
|
import { checkTransportFit, reusedRequestRefusal } from './transport-fit-check.js';
|
|
41
35
|
import { auditLog } from '@cspeach/sap-client';
|
|
42
36
|
import { ERR } from '../errors/codes.js';
|
|
43
37
|
import { interceptUpdateMethod, CancelledByUserError, } from './update-method-intercept.js';
|
|
44
|
-
import { resolveWriteTransport } from './transport-resolution.js';
|
|
45
38
|
// F-L2-1: statusline-only wire — records the transport a write ACTUALLY
|
|
46
39
|
// stamped so the `tr` segment stops reading `none` for plan-supplied
|
|
47
40
|
// transports. Never touches the session transport pointer.
|
|
48
41
|
import { noteWriteTransport, setCurrentTransport } from '../repl/current-transport.js';
|
|
49
42
|
import { markTransportCreatedThisSession } from './transport.js';
|
|
50
|
-
import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
|
|
51
|
-
import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
|
|
52
43
|
import { hasManualLocks, noteManualLock, noteManualUnlock } from '../sap/session-end.js';
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return undefined;
|
|
108
|
-
const errors = (a.messages ?? []).filter((m) => m.severity === 'error').map((m) => m.shortText ?? '').filter(Boolean).slice(0, 5);
|
|
109
|
-
if (!a.success)
|
|
110
|
-
return { success: false, verified: false, errors };
|
|
111
|
-
const v = await verifyActive(ctx.adt, name, type);
|
|
112
|
-
if (v.verifyError !== undefined)
|
|
113
|
-
return { success: true, verified: false, errors, verifyError: v.verifyError };
|
|
114
|
-
return v.active ? { success: true, verified: true, errors } : { success: true, verified: false, errors, stillInactive: true };
|
|
115
|
-
}
|
|
116
|
-
/** Round 8 item 5 + A-I1 — the change-frame state from the verified activation. */
|
|
117
|
-
function chainActivation(w) {
|
|
118
|
-
if (!w)
|
|
119
|
-
return undefined;
|
|
120
|
-
if (!w.success)
|
|
121
|
-
return { state: 'failed', detail: w.errors[0] };
|
|
122
|
-
if (w.verified)
|
|
123
|
-
return { state: 'active' };
|
|
124
|
-
return w.stillInactive ? { state: 'still_inactive' } : { state: 'unverified' };
|
|
125
|
-
}
|
|
126
|
-
/**
|
|
127
|
-
* 1.1.31 — the write itself activated and verified an object of the joined team
|
|
128
|
-
* project: CSPeach wrote it, so the fix is recorded for the developer (rehearsal
|
|
129
|
-
* 2026-10-07: the refactor's write activated the class; no separate sap_activate
|
|
130
|
-
* ran, so the hook in sap_activate alone never fired).
|
|
131
|
-
*/
|
|
132
|
-
/** Guided clean-core journey on: the fix lines in solo words (register/offer.ts). */
|
|
133
|
-
const journeyWording = (ctx) => (isJourneyActive(ctx.session) ? { wording: 'journey' } : {});
|
|
134
|
-
function recordOwnFixAfterWrite(ctx, type, name, transport, w) {
|
|
135
|
-
if (!w?.success || !w.verified)
|
|
136
|
-
return;
|
|
137
|
-
const written = new Map([[String(name).trim().toUpperCase(), String(transport ?? '').trim().toUpperCase()]]);
|
|
138
|
-
for (const line of teamFixOffers([{ type, name }], { written, ...journeyWording(ctx) }))
|
|
139
|
-
ctx.chunkEmitter?.emit('info', line);
|
|
140
|
-
}
|
|
141
|
-
function activatedByWrite(w) {
|
|
142
|
-
if (!w)
|
|
143
|
-
return {};
|
|
144
|
-
return { activated_by_write: { success: w.success, verified: w.verified, errors: w.errors } };
|
|
145
|
-
}
|
|
146
|
-
/** A-I1 minor — after a verified activation on write, say so instead of "activation_allowed". */
|
|
147
|
-
function nextStepAfterWrite(syntaxOk, w) {
|
|
148
|
-
if (!syntaxOk)
|
|
149
|
-
return 'activation_blocked_until_syntax_errors_fixed';
|
|
150
|
-
if (w?.success && w.verified)
|
|
151
|
-
return 'already activated by the write — verify with sap_inactive_objects before reporting it active';
|
|
152
|
-
return 'activation_allowed';
|
|
153
|
-
}
|
|
154
|
-
/** Compact ①②③④ pipeline summary — derived from the real gate results.
|
|
155
|
-
* Panels look: a change card (same honest chain in its footer). */
|
|
156
|
-
function emitWriteChain(ctx, input) {
|
|
157
|
-
const text = getLook() === 'panels' && input.card
|
|
158
|
-
? buildChangeCard(input, getEffectiveBodyWidth())
|
|
159
|
-
: buildWriteChainLine(input);
|
|
160
|
-
ctx.chunkEmitter?.emit('chunk', text + '\n');
|
|
161
|
-
}
|
|
162
|
-
/** Old source for the change card: ONLY from a snapshot actually taken. */
|
|
163
|
-
function snapshotSource(snap) {
|
|
164
|
-
if (!snap?.taken || !snap.entry?.filePath)
|
|
165
|
-
return undefined;
|
|
166
|
-
try {
|
|
167
|
-
return readFileSync(snap.entry.filePath, 'utf8');
|
|
168
|
-
}
|
|
169
|
-
catch {
|
|
170
|
-
return undefined;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
44
|
+
import { snapshotChainState, writtenWithSyntaxErrors, chainActivation, journeyWording, activatedByWrite, nextStepAfterWrite, snapshotSource, writeToolHandler, } from './write-pipeline.js';
|
|
45
|
+
const SET_SOURCE_SPEC = {
|
|
46
|
+
tool: 'sap_set_source',
|
|
47
|
+
approval: {
|
|
48
|
+
object: (a) => a.name,
|
|
49
|
+
op: 'modify',
|
|
50
|
+
claims: (a, ctx) => ({ type: a.type, transport: a.transport ?? ctx.currentTransport?.get() ?? null }),
|
|
51
|
+
},
|
|
52
|
+
transport: { mode: 'resolve', type: (a) => a.type, name: (a) => a.name, grantMismatch: true },
|
|
53
|
+
snapshot: { kind: 'source', hard: true, type: (a) => a.type, name: (a) => a.name },
|
|
54
|
+
safetyConfirm: (a, transport) => ({
|
|
55
|
+
rule: 'RULE_7_SNAPSHOT_BEFORE_WRITE',
|
|
56
|
+
op: 'set_source',
|
|
57
|
+
object: { type: a.type, name: a.name },
|
|
58
|
+
what_will_happen: 'Overwrites full object source; snapshot is taken first.',
|
|
59
|
+
rollback_available: true,
|
|
60
|
+
transport,
|
|
61
|
+
args: { ...a },
|
|
62
|
+
}),
|
|
63
|
+
walArgs: (a) => ({ name: a.name, type: a.type }),
|
|
64
|
+
call: (a, ctx, pre) => ctx.adt.setSource(a.type, a.name, a.source, pre.transport),
|
|
65
|
+
errorCode: 'write_failed',
|
|
66
|
+
landed: (r) => (r.error && !writtenWithSyntaxErrors(r)) || (r.writeStatus !== undefined && r.writeStatus >= 400)
|
|
67
|
+
? { ok: false, detail: r.error ?? `Write returned HTTP ${r.writeStatus} but did not throw`, extra: { write_status: r.writeStatus } }
|
|
68
|
+
: { ok: true },
|
|
69
|
+
audit: { method: 'setSource', type: (a) => a.type, name: (a) => a.name },
|
|
70
|
+
verify: { syntax: true, activation: true },
|
|
71
|
+
chain: (a, pre, syntax, act) => {
|
|
72
|
+
// Old source is read (once) only for the panels card; legacy reads nothing extra.
|
|
73
|
+
const oldSource = getLook() === 'panels' ? snapshotSource(pre.snapshot) : undefined;
|
|
74
|
+
return {
|
|
75
|
+
...snapshotChainState(pre.snapshot),
|
|
76
|
+
written: true,
|
|
77
|
+
syntax: { ok: syntax.ok, errors: syntax.errors },
|
|
78
|
+
activation: chainActivation(act),
|
|
79
|
+
card: {
|
|
80
|
+
object: a.name,
|
|
81
|
+
what: 'source',
|
|
82
|
+
transport: pre.transport,
|
|
83
|
+
oldSource,
|
|
84
|
+
newSource: oldSource !== undefined ? a.source : undefined,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
envelope: (_a, _r, pre, syntax, act) => ({
|
|
89
|
+
written: true,
|
|
90
|
+
syntax_ok: syntax.ok,
|
|
91
|
+
errors: syntax.errors,
|
|
92
|
+
warnings: syntax.warnings,
|
|
93
|
+
...activatedByWrite(act),
|
|
94
|
+
...(pre.owning ? { transport_used: pre.owning, transport_note: pre.note } : {}),
|
|
95
|
+
next_step: nextStepAfterWrite(syntax.ok, act),
|
|
96
|
+
}),
|
|
97
|
+
};
|
|
173
98
|
registerTool({
|
|
174
99
|
name: 'sap_set_source',
|
|
100
|
+
requires: byObjectType,
|
|
175
101
|
description: 'Overwrite the full source of an ABAP object. Requires approval_id. '
|
|
176
102
|
+ 'Use for class-definition changes, new methods, or any change not confined to '
|
|
177
103
|
+ 'a single method body. For method-body-only edits use sap_update_method (Rule 7a). '
|
|
@@ -189,175 +115,89 @@ registerTool({
|
|
|
189
115
|
},
|
|
190
116
|
required: ['name', 'type', 'source', 'approval_id'],
|
|
191
117
|
},
|
|
192
|
-
handler:
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
// is informational only and does NOT block the operation.
|
|
213
|
-
{
|
|
214
|
-
const sessionTransport = ctx.currentTransport?.get() ?? null;
|
|
215
|
-
if (args.transport && sessionTransport && args.transport !== sessionTransport) {
|
|
216
|
-
ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
// ── Rule 9 owning-transport check (D15/D20) ──────────────────────────────
|
|
220
|
-
// If the object is already locked in an open request, write under THAT
|
|
221
|
-
// request — any other transport is a guaranteed CTS lock conflict.
|
|
222
|
-
const resolved = await resolveWriteTransport(ctx, args.type, args.name, args.transport);
|
|
223
|
-
// 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
|
|
224
|
-
if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
|
|
225
|
-
return {
|
|
226
|
-
content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(args.name, resolved.owning) }),
|
|
227
|
-
is_error: true,
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
// ── Gate 2: Snapshot (Rule 7) — HARD gate ────────────────────────────────
|
|
231
|
-
// Only a genuine 404 (new object) may proceed snapshot-less. A read
|
|
232
|
-
// failure on a possibly-EXISTING object means no recovery copy → block.
|
|
233
|
-
let snapOutcome;
|
|
234
|
-
try {
|
|
235
|
-
snapOutcome = await autoSnapshot(args.name, args.type, ctx.adt);
|
|
236
|
-
}
|
|
237
|
-
catch (err) {
|
|
238
|
-
return {
|
|
239
|
-
content: JSON.stringify({ error: ERR.SNAPSHOT_FAILED, detail: String(err) }),
|
|
240
|
-
is_error: true,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
if (snapOutcome?.reason === 'source_read_failed') {
|
|
244
|
-
return {
|
|
245
|
-
content: JSON.stringify({
|
|
246
|
-
error: ERR.SNAPSHOT_FAILED,
|
|
247
|
-
reason: 'source_read_failed',
|
|
248
|
-
detail: snapOutcome.detail,
|
|
249
|
-
recovery: 'Pre-write source read failed (non-404) and no committed active version could be confirmed — the object may exist with source we could not read, so no snapshot was possible. If this is a live connection/auth problem, fix it and retry. If this is a freshly-created shell that got edit-locked by an earlier aborted write, recover it directly: unlock it (or activate the empty shell, then delete it) and re-run the create+write. Do not bypass Rule 7 by writing over source you never read.',
|
|
250
|
-
}),
|
|
251
|
-
is_error: true,
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
// Fresh/never-activated shell (no active version → nothing to snapshot):
|
|
255
|
-
// proceed like a new object, but say so LOUDLY and distinctly.
|
|
256
|
-
if (snapOutcome?.reason === 'new_shell')
|
|
257
|
-
emitNewShellNote(ctx, args.type, args.name);
|
|
258
|
-
emitSnapshotNotice(ctx, snapOutcome, args.type, args.name);
|
|
259
|
-
// ── Gate 3: Safety confirmation (Rule 7) ─────────────────────────────────
|
|
260
|
-
if (isSafetyConfirmEnabled()) {
|
|
261
|
-
const r = await presentSafetyConfirmation({
|
|
262
|
-
rule: 'RULE_7_SNAPSHOT_BEFORE_WRITE',
|
|
263
|
-
op: 'set_source',
|
|
264
|
-
object: { type: args.type, name: args.name },
|
|
265
|
-
what_will_happen: 'Overwrites full object source; snapshot is taken first.',
|
|
266
|
-
rollback_available: true,
|
|
267
|
-
transport: resolved.transport ?? ctx.currentTransport?.get() ?? '',
|
|
268
|
-
args: { ...args },
|
|
269
|
-
});
|
|
270
|
-
if (!r.confirmed) {
|
|
271
|
-
return {
|
|
272
|
-
content: JSON.stringify({ error: 'cancelled_by_user', reason: r.reason }),
|
|
273
|
-
is_error: true,
|
|
274
|
-
};
|
|
275
|
-
}
|
|
118
|
+
handler: writeToolHandler(SET_SOURCE_SPEC),
|
|
119
|
+
});
|
|
120
|
+
/** Rule 7 recovery text in the class wording (sap_update_method always targets a class). */
|
|
121
|
+
const CLASS_READ_RECOVERY = 'Pre-write source read failed (non-404) and no committed active version could be confirmed — the class may exist with source we could not read, so no snapshot was possible. If this is a live connection/auth problem, fix it and retry. If this is a freshly-created class shell that got edit-locked by an earlier aborted write, recover it directly: unlock it (or activate the empty shell, then delete it) and re-run the create+write. Do not bypass Rule 7 by writing over source you never read.';
|
|
122
|
+
const UPDATE_METHOD_SPEC = {
|
|
123
|
+
tool: 'sap_update_method',
|
|
124
|
+
approval: {
|
|
125
|
+
object: (a) => a.class_name,
|
|
126
|
+
op: 'modify',
|
|
127
|
+
claims: (a, ctx) => ({ type: 'CLAS', transport: a.transport ?? ctx.currentTransport?.get() ?? null }),
|
|
128
|
+
},
|
|
129
|
+
transport: { mode: 'resolve', type: () => 'CLAS', name: (a) => a.class_name, grantMismatch: true },
|
|
130
|
+
// sap_update_method always targets an EXISTING class, so a read failure
|
|
131
|
+
// (non-404) means no recovery copy → block. (A genuine 404 falls through
|
|
132
|
+
// here but the preview gate fails on the same missing class.)
|
|
133
|
+
snapshot: { kind: 'source', hard: true, type: () => 'CLAS', name: (a) => a.class_name, recovery: CLASS_READ_RECOVERY },
|
|
134
|
+
// Gate 3: diff preview & confirmation (Rule 7a).
|
|
135
|
+
preGate: async (a, ctx, pre) => {
|
|
136
|
+
if (!ctx.previewHook) {
|
|
137
|
+
return { error: { error: 'preview_hook_unavailable', detail: 'cannot confirm method body changes in this execution context' } };
|
|
276
138
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
//
|
|
287
|
-
// CRITICAL: AdtClient.setSource only THROWS for catastrophic errors (network
|
|
288
|
-
// down, all retries exhausted). For HTTP 4xx responses (e.g., the second PUT
|
|
289
|
-
// after 423-recovery still failing) it RETURNS a WriteResult with
|
|
290
|
-
// writeStatus >= 400 and `error: '...'`. The handler MUST check the return
|
|
291
|
-
// value — without it, we silently report success on a failed write.
|
|
292
|
-
// Regression evidence + diagnosis: memory/project_phase3_lite_regression.md.
|
|
293
|
-
let writeResult;
|
|
139
|
+
const minimal = {
|
|
140
|
+
getSource: ({ type, name }) => ctx.adt.getSource(type, name),
|
|
141
|
+
};
|
|
142
|
+
const call = {
|
|
143
|
+
className: a.class_name,
|
|
144
|
+
methodName: a.method_name,
|
|
145
|
+
methodSource: a.method_source,
|
|
146
|
+
transport: pre.transport ?? ctx.currentTransport?.get() ?? '',
|
|
147
|
+
};
|
|
294
148
|
try {
|
|
295
|
-
|
|
149
|
+
await interceptUpdateMethod(minimal, call, ctx.previewHook);
|
|
296
150
|
}
|
|
297
151
|
catch (err) {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
return {
|
|
301
|
-
content: JSON.stringify({ error: 'write_failed', detail: errStr }),
|
|
302
|
-
is_error: true,
|
|
303
|
-
};
|
|
152
|
+
if (err instanceof CancelledByUserError)
|
|
153
|
+
return { error: { error: 'cancelled_by_user', detail: err.message } };
|
|
154
|
+
return { error: { error: 'preview_failed', detail: String(err) } };
|
|
304
155
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
156
|
+
return { ok: true };
|
|
157
|
+
},
|
|
158
|
+
walArgs: (a) => ({ class_name: a.class_name, method_name: a.method_name }),
|
|
159
|
+
// Same return semantics as setSource — RETURNS a WriteResult on HTTP-level failure.
|
|
160
|
+
call: (a, ctx, pre) => ctx.adt.updateMethod(a.class_name, a.method_name, a.method_source, pre.transport),
|
|
161
|
+
// Bug 13 (2026-05-15): updateMethod throws SapError(409) when the class has
|
|
162
|
+
// pending inactive changes (split state) — a recoverable envelope, so the
|
|
163
|
+
// model suggests activating / discarding the inactive version first.
|
|
164
|
+
onThrow: (err, a) => {
|
|
165
|
+
const errStr = String(err);
|
|
166
|
+
const httpStatus = err?.httpStatus;
|
|
167
|
+
if (httpStatus === 409 && /pending inactive/i.test(errStr)) {
|
|
308
168
|
return {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
}),
|
|
314
|
-
is_error: true,
|
|
169
|
+
error: 'split_state',
|
|
170
|
+
detail: errStr,
|
|
171
|
+
class_name: a.class_name,
|
|
172
|
+
recovery: 'Activate (sap_activate) or discard the inactive version of this class, then retry sap_update_method.',
|
|
315
173
|
};
|
|
316
174
|
}
|
|
317
|
-
|
|
318
|
-
noteWriteTransport(resolved.transport); // F-L2-1 statusline wire
|
|
319
|
-
// ── Gate 4: Verify — syntax check (Rule 10) ──────────────────────────────
|
|
320
|
-
const syntax = await verifySyntax(ctx.adt, args.name, args.type);
|
|
321
|
-
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ written: true, syntax }), false, Date.now() - startedAt);
|
|
322
|
-
const writeActivation = await verifyWriteActivation(ctx, writeResult, args.name, args.type);
|
|
323
|
-
// Tour slide-7 chain — derived from the gates that actually ran.
|
|
324
|
-
// Old source is read (once) only for the panels card; legacy reads nothing extra.
|
|
325
|
-
const oldSource = getLook() === 'panels' ? snapshotSource(snapOutcome) : undefined;
|
|
326
|
-
emitWriteChain(ctx, {
|
|
327
|
-
...snapshotChainState(snapOutcome),
|
|
328
|
-
written: true,
|
|
329
|
-
syntax: { ok: syntax.ok, errors: syntax.errors },
|
|
330
|
-
activation: chainActivation(writeActivation),
|
|
331
|
-
card: {
|
|
332
|
-
object: args.name,
|
|
333
|
-
what: 'source',
|
|
334
|
-
transport: resolved.transport,
|
|
335
|
-
oldSource,
|
|
336
|
-
newSource: oldSource !== undefined ? args.source : undefined,
|
|
337
|
-
},
|
|
338
|
-
});
|
|
339
|
-
recordOwnFixAfterWrite(ctx, args.type, args.name, resolved.owning ?? resolved.transport, writeActivation);
|
|
340
|
-
return {
|
|
341
|
-
content: JSON.stringify({
|
|
342
|
-
written: true,
|
|
343
|
-
syntax_ok: syntax.ok,
|
|
344
|
-
errors: syntax.errors,
|
|
345
|
-
warnings: syntax.warnings,
|
|
346
|
-
...activatedByWrite(writeActivation),
|
|
347
|
-
...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
|
|
348
|
-
next_step: nextStepAfterWrite(syntax.ok, writeActivation),
|
|
349
|
-
}),
|
|
350
|
-
};
|
|
175
|
+
return undefined;
|
|
351
176
|
},
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
177
|
+
errorCode: 'update_failed',
|
|
178
|
+
landed: (r) => (r.error && !writtenWithSyntaxErrors(r)) || (r.writeStatus !== undefined && r.writeStatus >= 400)
|
|
179
|
+
? { ok: false, detail: r.error ?? `Update returned HTTP ${r.writeStatus} but did not throw`, extra: { write_status: r.writeStatus } }
|
|
180
|
+
: { ok: true },
|
|
181
|
+
audit: { method: 'updateMethod', type: () => 'CLAS', name: (a) => a.class_name, detail: (a) => `method=${a.method_name}` },
|
|
182
|
+
verify: { syntax: true, activation: true },
|
|
183
|
+
walResult: (_a, _r, _pre, syntax) => ({ updated: true, syntax }),
|
|
184
|
+
chain: (a, pre, syntax, act) => ({
|
|
185
|
+
...snapshotChainState(pre.snapshot),
|
|
186
|
+
written: true,
|
|
187
|
+
syntax: { ok: syntax.ok, errors: syntax.errors },
|
|
188
|
+
activation: chainActivation(act),
|
|
189
|
+
card: { object: a.class_name, what: `method ${a.method_name}`, transport: pre.transport },
|
|
190
|
+
}),
|
|
191
|
+
envelope: (_a, _r, pre, syntax, act) => ({
|
|
192
|
+
updated: true,
|
|
193
|
+
syntax_ok: syntax.ok,
|
|
194
|
+
errors: syntax.errors,
|
|
195
|
+
warnings: syntax.warnings,
|
|
196
|
+
...activatedByWrite(act),
|
|
197
|
+
...(pre.owning ? { transport_used: pre.owning, transport_note: pre.note } : {}),
|
|
198
|
+
next_step: nextStepAfterWrite(syntax.ok, act),
|
|
199
|
+
}),
|
|
200
|
+
};
|
|
361
201
|
registerTool({
|
|
362
202
|
name: 'sap_update_method',
|
|
363
203
|
description: 'Replace the body of a single method in an existing class. '
|
|
@@ -378,195 +218,45 @@ registerTool({
|
|
|
378
218
|
},
|
|
379
219
|
required: ['class_name', 'method_name', 'method_source', 'approval_id'],
|
|
380
220
|
},
|
|
381
|
-
handler:
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const
|
|
410
|
-
// 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
|
|
411
|
-
if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
|
|
412
|
-
return {
|
|
413
|
-
content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(args.class_name, resolved.owning) }),
|
|
414
|
-
is_error: true,
|
|
415
|
-
};
|
|
416
|
-
}
|
|
417
|
-
// ── Gate 2: Snapshot (Rule 7) — HARD gate ────────────────────────────────
|
|
418
|
-
// sap_update_method always targets an EXISTING class, so a read failure
|
|
419
|
-
// (non-404) means no recovery copy → block. (A genuine 404 falls through
|
|
420
|
-
// here but the preview gate below fails on the same missing class.)
|
|
421
|
-
let snapOutcome;
|
|
422
|
-
try {
|
|
423
|
-
snapOutcome = await autoSnapshot(args.class_name, 'CLAS', ctx.adt);
|
|
424
|
-
}
|
|
425
|
-
catch (err) {
|
|
426
|
-
return {
|
|
427
|
-
content: JSON.stringify({ error: ERR.SNAPSHOT_FAILED, detail: String(err) }),
|
|
428
|
-
is_error: true,
|
|
429
|
-
};
|
|
430
|
-
}
|
|
431
|
-
if (snapOutcome?.reason === 'source_read_failed') {
|
|
432
|
-
return {
|
|
433
|
-
content: JSON.stringify({
|
|
434
|
-
error: ERR.SNAPSHOT_FAILED,
|
|
435
|
-
reason: 'source_read_failed',
|
|
436
|
-
detail: snapOutcome.detail,
|
|
437
|
-
recovery: 'Pre-write source read failed (non-404) and no committed active version could be confirmed — the class may exist with source we could not read, so no snapshot was possible. If this is a live connection/auth problem, fix it and retry. If this is a freshly-created class shell that got edit-locked by an earlier aborted write, recover it directly: unlock it (or activate the empty shell, then delete it) and re-run the create+write. Do not bypass Rule 7 by writing over source you never read.',
|
|
438
|
-
}),
|
|
439
|
-
is_error: true,
|
|
440
|
-
};
|
|
441
|
-
}
|
|
442
|
-
// Fresh/never-activated class shell (no active version → nothing to
|
|
443
|
-
// snapshot): proceed like a new object, but say so LOUDLY and distinctly.
|
|
444
|
-
if (snapOutcome?.reason === 'new_shell')
|
|
445
|
-
emitNewShellNote(ctx, 'CLAS', args.class_name);
|
|
446
|
-
emitSnapshotNotice(ctx, snapOutcome, 'CLAS', args.class_name);
|
|
447
|
-
// ── Gate 3: Diff preview & confirmation (Rule 7a) ────────────────────────
|
|
448
|
-
if (!ctx.previewHook) {
|
|
449
|
-
return {
|
|
450
|
-
content: JSON.stringify({
|
|
451
|
-
error: 'preview_hook_unavailable',
|
|
452
|
-
detail: 'cannot confirm method body changes in this execution context',
|
|
453
|
-
}),
|
|
454
|
-
is_error: true,
|
|
455
|
-
};
|
|
456
|
-
}
|
|
457
|
-
const minimal = {
|
|
458
|
-
getSource: ({ type, name }) => ctx.adt.getSource(type, name),
|
|
459
|
-
};
|
|
460
|
-
const call = {
|
|
461
|
-
className: args.class_name,
|
|
462
|
-
methodName: args.method_name,
|
|
463
|
-
methodSource: args.method_source,
|
|
464
|
-
transport: resolved.transport ?? ctx.currentTransport?.get() ?? '',
|
|
465
|
-
};
|
|
466
|
-
try {
|
|
467
|
-
await interceptUpdateMethod(minimal, call, ctx.previewHook);
|
|
468
|
-
}
|
|
469
|
-
catch (err) {
|
|
470
|
-
if (err instanceof CancelledByUserError) {
|
|
471
|
-
return {
|
|
472
|
-
content: JSON.stringify({ error: 'cancelled_by_user', detail: err.message }),
|
|
473
|
-
is_error: true,
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
|
-
return {
|
|
477
|
-
content: JSON.stringify({ error: 'preview_failed', detail: String(err) }),
|
|
478
|
-
is_error: true,
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
// ── WAL: record pending entry ────────────────────────────────────────────
|
|
482
|
-
await appendPending(ctx.session, {
|
|
483
|
-
tool_use_id: toolUseId,
|
|
484
|
-
tool: 'sap_update_method',
|
|
485
|
-
args: { class_name: args.class_name, method_name: args.method_name },
|
|
486
|
-
started_at: new Date().toISOString(),
|
|
487
|
-
});
|
|
488
|
-
// ── Write ────────────────────────────────────────────────────────────────
|
|
489
|
-
// updateMethod(className, methodName, methodBody, transport?). Same return
|
|
490
|
-
// semantics as setSource — RETURNS a WriteResult on HTTP-level failure
|
|
491
|
-
// rather than throwing. Must check writeStatus / error on the return value.
|
|
492
|
-
//
|
|
493
|
-
// Bug 13 (2026-05-15): AdtClient.updateMethod now throws SapError(409)
|
|
494
|
-
// when the class has pending inactive changes (split state). Surface that
|
|
495
|
-
// as a recoverable `split_state` envelope so the model can suggest the
|
|
496
|
-
// user activate / discard the inactive version before retrying — without
|
|
497
|
-
// silently overwriting the inactive-only declarations.
|
|
498
|
-
let updateResult;
|
|
499
|
-
try {
|
|
500
|
-
updateResult = await ctx.adt.updateMethod(args.class_name, args.method_name, args.method_source, resolved.transport);
|
|
501
|
-
}
|
|
502
|
-
catch (err) {
|
|
503
|
-
const errStr = String(err);
|
|
504
|
-
const httpStatus = err?.httpStatus;
|
|
505
|
-
if (httpStatus === 409 && /pending inactive/i.test(errStr)) {
|
|
506
|
-
await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
|
|
507
|
-
return {
|
|
508
|
-
content: JSON.stringify({
|
|
509
|
-
error: 'split_state',
|
|
510
|
-
detail: errStr,
|
|
511
|
-
class_name: args.class_name,
|
|
512
|
-
recovery: 'Activate (sap_activate) or discard the inactive version of this class, then retry sap_update_method.',
|
|
513
|
-
}),
|
|
514
|
-
is_error: true,
|
|
515
|
-
};
|
|
516
|
-
}
|
|
517
|
-
await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
|
|
518
|
-
return {
|
|
519
|
-
content: JSON.stringify({ error: 'update_failed', detail: errStr }),
|
|
520
|
-
is_error: true,
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
|
-
if ((updateResult.error && !writtenWithSyntaxErrors(updateResult)) || (updateResult.writeStatus !== undefined && updateResult.writeStatus >= 400)) {
|
|
524
|
-
const detail = updateResult.error ?? `Update returned HTTP ${updateResult.writeStatus} but did not throw`;
|
|
525
|
-
await finalizeToolCall(ctx.session, toolUseId, detail, true, Date.now() - startedAt);
|
|
526
|
-
return {
|
|
527
|
-
content: JSON.stringify({
|
|
528
|
-
error: 'update_failed',
|
|
529
|
-
detail,
|
|
530
|
-
write_status: updateResult.writeStatus,
|
|
531
|
-
}),
|
|
532
|
-
is_error: true,
|
|
533
|
-
};
|
|
534
|
-
}
|
|
535
|
-
await auditLog('updateMethod', 'CLAS', args.class_name, 'success', Date.now() - startedAt, `method=${args.method_name}`);
|
|
536
|
-
noteWriteTransport(resolved.transport); // F-L2-1 statusline wire
|
|
537
|
-
// ── Gate 4: Verify — syntax check (Rule 10) ──────────────────────────────
|
|
538
|
-
const syntax = await verifySyntax(ctx.adt, args.class_name, 'CLAS');
|
|
539
|
-
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ updated: true, syntax }), false, Date.now() - startedAt);
|
|
540
|
-
const writeActivation = await verifyWriteActivation(ctx, updateResult, args.class_name, 'CLAS');
|
|
541
|
-
// Tour slide-7 chain — derived from the gates that actually ran.
|
|
542
|
-
emitWriteChain(ctx, {
|
|
543
|
-
...snapshotChainState(snapOutcome),
|
|
544
|
-
written: true,
|
|
545
|
-
syntax: { ok: syntax.ok, errors: syntax.errors },
|
|
546
|
-
activation: chainActivation(writeActivation),
|
|
547
|
-
card: { object: args.class_name, what: `method ${args.method_name}`, transport: resolved.transport },
|
|
548
|
-
});
|
|
549
|
-
recordOwnFixAfterWrite(ctx, 'CLAS', args.class_name, resolved.owning ?? resolved.transport, writeActivation);
|
|
221
|
+
handler: writeToolHandler(UPDATE_METHOD_SPEC),
|
|
222
|
+
});
|
|
223
|
+
const DELETE_OBJECT_SPEC = {
|
|
224
|
+
tool: 'sap_delete_object',
|
|
225
|
+
approval: { object: (a) => a.name, op: 'delete' },
|
|
226
|
+
transport: { mode: 'resolve', type: (a) => a.type, name: (a) => a.name, grantMismatch: false },
|
|
227
|
+
// Best-effort: the object may be a shell with no readable source.
|
|
228
|
+
snapshot: { kind: 'source', hard: false, type: (a) => a.type, name: (a) => a.name },
|
|
229
|
+
// Rule 7 — delete is irreversible-ish.
|
|
230
|
+
safetyConfirm: (a, transport) => ({
|
|
231
|
+
rule: 'RULE_7_SNAPSHOT_BEFORE_WRITE',
|
|
232
|
+
op: 'delete_object',
|
|
233
|
+
object: { type: a.type, name: a.name },
|
|
234
|
+
what_will_happen: 'Deletes object from SAP; snapshot is taken first; recovery via sap_snapshot_read + sap_set_source (LLM composes the rollback).',
|
|
235
|
+
rollback_available: true,
|
|
236
|
+
transport,
|
|
237
|
+
args: { ...a },
|
|
238
|
+
}),
|
|
239
|
+
walArgs: (a) => ({ name: a.name, type: a.type }),
|
|
240
|
+
// Same return semantics as setSource — RETURNS rather than throws on HTTP 4xx.
|
|
241
|
+
call: (a, ctx, pre) => ctx.adt.deleteObject(a.type, a.name, pre.transport),
|
|
242
|
+
errorCode: 'delete_failed',
|
|
243
|
+
landed: (r) => r.error || (r.status !== undefined && r.status >= 400)
|
|
244
|
+
? { ok: false, detail: r.error ?? `Delete returned HTTP ${r.status} but did not throw`, extra: { status: r.status } }
|
|
245
|
+
: { ok: true },
|
|
246
|
+
audit: { method: 'deleteObject', type: (a) => a.type, name: (a) => a.name },
|
|
247
|
+
verify: { syntax: false, activation: false, deletedCheck: true },
|
|
248
|
+
envelope: (_a, _r, pre) => {
|
|
249
|
+
const stillExists = pre.extra['still_exists'];
|
|
550
250
|
return {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
errors: syntax.errors,
|
|
555
|
-
warnings: syntax.warnings,
|
|
556
|
-
...activatedByWrite(writeActivation),
|
|
557
|
-
...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
|
|
558
|
-
next_step: nextStepAfterWrite(syntax.ok, writeActivation),
|
|
559
|
-
}),
|
|
251
|
+
deleted: !stillExists,
|
|
252
|
+
still_exists: stillExists,
|
|
253
|
+
...(pre.owning ? { transport_used: pre.owning, transport_note: pre.note } : {}),
|
|
560
254
|
};
|
|
561
255
|
},
|
|
562
|
-
}
|
|
563
|
-
// ── sap_delete_object ────────────────────────────────────────────────────────
|
|
564
|
-
//
|
|
565
|
-
// Signature: deleteObject(type, name, transport?) → { status: number; error?: string }
|
|
566
|
-
// AdtClient auto-snapshots before delete (Rule 7 built-in), but we also call
|
|
567
|
-
// autoSnapshot here for belt-and-suspenders consistency with the CLI gate model.
|
|
256
|
+
};
|
|
568
257
|
registerTool({
|
|
569
258
|
name: 'sap_delete_object',
|
|
259
|
+
requires: byObjectType,
|
|
570
260
|
description: 'Delete an ABAP object. Requires approval_id. '
|
|
571
261
|
+ 'A snapshot is taken automatically before deletion. '
|
|
572
262
|
+ 'Post-check: confirms the object no longer exists. '
|
|
@@ -583,115 +273,119 @@ registerTool({
|
|
|
583
273
|
},
|
|
584
274
|
required: ['name', 'type', 'approval_id'],
|
|
585
275
|
},
|
|
586
|
-
handler:
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
}
|
|
614
|
-
catch {
|
|
615
|
-
// Silently ignore — object may be a shell with no readable source.
|
|
616
|
-
}
|
|
617
|
-
emitSnapshotNotice(ctx, snapOutcome, args.type, args.name);
|
|
618
|
-
// ── Gate 3: Safety confirmation (Rule 7 — delete is irreversible-ish) ────
|
|
619
|
-
if (isSafetyConfirmEnabled()) {
|
|
620
|
-
const r = await presentSafetyConfirmation({
|
|
621
|
-
rule: 'RULE_7_SNAPSHOT_BEFORE_WRITE',
|
|
622
|
-
op: 'delete_object',
|
|
623
|
-
object: { type: args.type, name: args.name },
|
|
624
|
-
what_will_happen: 'Deletes object from SAP; snapshot is taken first; recovery via sap_snapshot_read + sap_set_source (LLM composes the rollback).',
|
|
625
|
-
rollback_available: true,
|
|
626
|
-
transport: resolved.transport ?? ctx.currentTransport?.get() ?? '',
|
|
627
|
-
args: { ...args },
|
|
628
|
-
});
|
|
629
|
-
if (!r.confirmed) {
|
|
630
|
-
return {
|
|
631
|
-
content: JSON.stringify({ error: 'cancelled_by_user', reason: r.reason }),
|
|
632
|
-
is_error: true,
|
|
633
|
-
};
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
// ── WAL: record pending entry ────────────────────────────────────────────
|
|
637
|
-
await appendPending(ctx.session, {
|
|
638
|
-
tool_use_id: toolUseId,
|
|
639
|
-
tool: 'sap_delete_object',
|
|
640
|
-
args: { name: args.name, type: args.type },
|
|
641
|
-
started_at: new Date().toISOString(),
|
|
642
|
-
});
|
|
643
|
-
// ── Delete ───────────────────────────────────────────────────────────────
|
|
644
|
-
// deleteObject(type, name, transport?) → { status: number; error?: string }.
|
|
645
|
-
// Same return semantics as setSource — RETURNS rather than throws on HTTP
|
|
646
|
-
// 4xx. Must check status / error on the return value.
|
|
647
|
-
let deleteResult;
|
|
276
|
+
handler: writeToolHandler(DELETE_OBJECT_SPEC),
|
|
277
|
+
});
|
|
278
|
+
const isDevcCreate = (a) => String(a.type ?? '').toUpperCase().split('/')[0].trim() === 'DEVC';
|
|
279
|
+
const devcTransportCreated = (pre) => pre.extra['devc_transport_created'];
|
|
280
|
+
const CREATE_OBJECT_SPEC = {
|
|
281
|
+
tool: 'sap_create_object',
|
|
282
|
+
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
283
|
+
approval: {
|
|
284
|
+
object: (a) => a.name, op: 'create',
|
|
285
|
+
claims: (a, ctx) => ({ type: a.type, package: a.package, transport: a.transport ?? ctx.currentTransport?.get() ?? null }),
|
|
286
|
+
},
|
|
287
|
+
transport: { mode: 'three-tier' },
|
|
288
|
+
snapshot: { kind: 'none', reason: 'a new object has no prior source' },
|
|
289
|
+
// 1.1.31: does the chosen request fit the package? (see transport-fit-check.ts)
|
|
290
|
+
// A package create decides its own transport in the walGate (§10a), so only objects.
|
|
291
|
+
preGate: async (a, ctx, pre) => {
|
|
292
|
+
if (isDevcCreate(a))
|
|
293
|
+
return { ok: true };
|
|
294
|
+
const fit = await checkTransportFit(ctx.adt, { type: a.type, name: a.name, pkg: a.package, transport: pre.transport });
|
|
295
|
+
return fit.ok ? { ok: true } : { error: fit.refusal };
|
|
296
|
+
},
|
|
297
|
+
walArgs: (a) => ({ name: a.name, type: a.type, package: a.package }),
|
|
298
|
+
// §10a: a new package and its first transport, under this ONE approval.
|
|
299
|
+
walGate: async (a, ctx, pre) => {
|
|
300
|
+
if (!isDevcCreate(a))
|
|
301
|
+
return { ok: true };
|
|
302
|
+
let flow;
|
|
648
303
|
try {
|
|
649
|
-
|
|
304
|
+
flow = await prepareNewPackage(a, ctx);
|
|
650
305
|
}
|
|
651
306
|
catch (err) {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
307
|
+
// A throw (network, auth, …) must still close the pending entry and
|
|
308
|
+
// reach the person in plain words, never as a raw exception.
|
|
309
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
310
|
+
const answered = typeof err?.status === 'number';
|
|
311
|
+
const message = answered
|
|
312
|
+
? `SAP refused to create the transport: ${msg}`
|
|
313
|
+
: `Could not reach SAP to create the transport: ${msg}`;
|
|
314
|
+
return { error: { error: 'transport_create_failed', message, next: 'Next: check the connection to SAP and try again.' }, log: message };
|
|
315
|
+
}
|
|
316
|
+
if ('refused' in flow)
|
|
317
|
+
return { error: flow.refused, log: flow.refused.message };
|
|
318
|
+
pre.transport = flow.corrNr;
|
|
319
|
+
if (flow.created) {
|
|
320
|
+
// Same auto-attach as sap_transport_create: later creates in the new
|
|
321
|
+
// package pick this request up through the 3-tier fallback.
|
|
322
|
+
setCurrentTransport(flow.created);
|
|
323
|
+
markTransportCreatedThisSession(flow.created);
|
|
658
324
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
325
|
+
return {
|
|
326
|
+
ok: true,
|
|
327
|
+
extra: {
|
|
328
|
+
devc_transport_created: flow.created,
|
|
329
|
+
devc_extras: {
|
|
330
|
+
softwareComponent: a.softwareComponent,
|
|
331
|
+
transportLayer: flow.layer,
|
|
332
|
+
applicationComponent: a.applicationComponent,
|
|
333
|
+
superPackage: a.superPackage ? String(a.superPackage).toUpperCase() : undefined,
|
|
334
|
+
responsible: a.responsible,
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
},
|
|
339
|
+
// createObject(objtype, name, packageName, description, transport?, extras?)
|
|
340
|
+
call: (a, ctx, pre) => ctx.adt.createObject(a.type, a.name, a.package, a.description, pre.transport, {
|
|
341
|
+
serviceDefinition: a.serviceDefinition,
|
|
342
|
+
bindingType: a.bindingType,
|
|
343
|
+
bindingVersion: a.bindingVersion,
|
|
344
|
+
...(pre.extra['devc_extras'] ?? {}),
|
|
345
|
+
// Review fix round 1, I-1: a sap_lock handle lives in the stateful
|
|
346
|
+
// session; sap-client ends that session before a create unless told.
|
|
347
|
+
keepSession: hasManualLocks(),
|
|
348
|
+
}),
|
|
349
|
+
onThrow: (err, _a, pre) => ({ error: 'create_failed', detail: String(err), ...orphanRequest(devcTransportCreated(pre)) }),
|
|
350
|
+
errorCode: 'create_failed',
|
|
351
|
+
landed: (r, pre) => r.status === 'created'
|
|
352
|
+
? { ok: true }
|
|
353
|
+
: { ok: false, detail: r.error, log: JSON.stringify(r), extra: orphanRequest(devcTransportCreated(pre)) },
|
|
354
|
+
audit: { method: 'createObject', type: (a) => a.type, name: (a) => a.name },
|
|
355
|
+
verify: { syntax: false, activation: false },
|
|
356
|
+
walResult: (_a, r) => r,
|
|
357
|
+
envelope: (a, r, pre) => {
|
|
358
|
+
if (isDevcCreate(a)) {
|
|
359
|
+
const pkgName = r.objectName || String(a.name).toUpperCase();
|
|
360
|
+
const created = devcTransportCreated(pre);
|
|
662
361
|
return {
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
}),
|
|
668
|
-
|
|
362
|
+
created: true,
|
|
363
|
+
object: pkgName,
|
|
364
|
+
type: 'DEVC',
|
|
365
|
+
...(pre.transport ? { transport: pre.transport } : {}),
|
|
366
|
+
...(created ? { transport_created: true } : {}),
|
|
367
|
+
message: pre.transport
|
|
368
|
+
? `Created package ${pkgName} on request ${pre.transport}.`
|
|
369
|
+
: `Created package ${pkgName} (no transport needed).`,
|
|
370
|
+
next_step: 'create_objects_in_the_package_on_this_transport',
|
|
669
371
|
};
|
|
670
372
|
}
|
|
671
|
-
await auditLog('deleteObject', args.type, args.name, 'success', Date.now() - startedAt);
|
|
672
|
-
noteWriteTransport(resolved.transport); // F-L2-1 statusline wire
|
|
673
|
-
// ── Post-check: object should no longer exist ────────────────────────────
|
|
674
|
-
const stillExists = await ctx.adt.objectStructure(args.type, args.name)
|
|
675
|
-
.then(() => true)
|
|
676
|
-
.catch(() => false);
|
|
677
|
-
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ deleted: !stillExists }), false, Date.now() - startedAt);
|
|
678
373
|
return {
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
374
|
+
created: true,
|
|
375
|
+
object: r.objectName,
|
|
376
|
+
type: r.objectType,
|
|
377
|
+
package: r.packageName,
|
|
378
|
+
// A message class has no source and is never activated: its messages
|
|
379
|
+
// go in through sap_message_maintain (bug 3, 2026-10-12).
|
|
380
|
+
next_step: String(r.objectType ?? '').toUpperCase() === 'MSAG'
|
|
381
|
+
? 'add_messages_via_sap_message_maintain (a message class has no source and no activation)'
|
|
382
|
+
: 'write_source_via_sap_set_source_then_activate',
|
|
684
383
|
};
|
|
685
384
|
},
|
|
686
|
-
}
|
|
687
|
-
// ── sap_create_object ────────────────────────────────────────────────────────
|
|
688
|
-
//
|
|
689
|
-
// Signature: createObject(objtype, name, packageName, description, transport?, extras?)
|
|
690
|
-
// → Promise<CreateResult>
|
|
691
|
-
// NOTE: arg order differs from the plan snippet — packageName comes before description.
|
|
692
|
-
// No snapshot needed: new object has no prior source.
|
|
385
|
+
};
|
|
693
386
|
registerTool({
|
|
694
387
|
name: 'sap_create_object',
|
|
388
|
+
requires: byObjectType,
|
|
695
389
|
description: 'Create a new ABAP object shell. '
|
|
696
390
|
+ 'Requires approval_id. No snapshot needed (new object). '
|
|
697
391
|
+ 'Write source separately via sap_set_source, then call sap_activate. '
|
|
@@ -724,162 +418,10 @@ registerTool({
|
|
|
724
418
|
applicationComponent: { type: 'string', description: 'DEVC ONLY (optional): application component, e.g. FI-AR.' },
|
|
725
419
|
superPackage: { type: 'string', description: 'DEVC ONLY (optional): parent package.' },
|
|
726
420
|
responsible: { type: 'string', description: 'DEVC ONLY (optional): responsible user. Default: the logged-on user (upper case).' },
|
|
727
|
-
},
|
|
728
|
-
required: ['name', 'type', 'package', 'description', 'approval_id'],
|
|
729
|
-
},
|
|
730
|
-
handler: async (args, ctx) => {
|
|
731
|
-
// ── Gate 1: Approval ────────────────────────────────────────────────────
|
|
732
|
-
const verify = await verifyAndSpendApprovalId(args.approval_id, args.name, 'create');
|
|
733
|
-
if (!verify.ok) {
|
|
734
|
-
return {
|
|
735
|
-
content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
|
|
736
|
-
is_error: true,
|
|
737
|
-
};
|
|
738
|
-
}
|
|
739
|
-
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
740
|
-
{
|
|
741
|
-
const claim = grantClaimMismatch(verify.payload, { type: args.type, package: args.package, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
|
|
742
|
-
if (claim)
|
|
743
|
-
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
744
|
-
}
|
|
745
|
-
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
746
|
-
const startedAt = Date.now();
|
|
747
|
-
// No snapshot — new object has no prior source.
|
|
748
|
-
// ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
|
|
749
|
-
// Emit a warning if the LLM passed an explicit transport that differs from
|
|
750
|
-
// the session current-transport. Prefer LLM intent (explicit caller); this
|
|
751
|
-
// is informational only and does NOT block the operation.
|
|
752
|
-
{
|
|
753
|
-
const sessionTransport = ctx.currentTransport?.get() ?? null;
|
|
754
|
-
if (args.transport && sessionTransport && args.transport !== sessionTransport) {
|
|
755
|
-
ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
// 3-tier fallback: explicit arg → session current-transport → undefined
|
|
759
|
-
let effectiveTransport = args.transport ?? ctx.currentTransport?.get() ?? undefined;
|
|
760
|
-
// ── 1.1.31: does the chosen request fit the package? (see transport-fit-check.ts)
|
|
761
|
-
// A package create decides its own transport below (§10a), so only objects.
|
|
762
|
-
const isDevc = String(args.type ?? '').toUpperCase().split('/')[0].trim() === 'DEVC';
|
|
763
|
-
if (!isDevc) {
|
|
764
|
-
const fit = await checkTransportFit(ctx.adt, {
|
|
765
|
-
type: args.type, name: args.name, pkg: args.package, transport: effectiveTransport,
|
|
766
|
-
});
|
|
767
|
-
if (!fit.ok)
|
|
768
|
-
return { content: JSON.stringify(fit.refusal), is_error: true };
|
|
769
|
-
}
|
|
770
|
-
// ── WAL: record pending entry ────────────────────────────────────────────
|
|
771
|
-
await appendPending(ctx.session, {
|
|
772
|
-
tool_use_id: toolUseId,
|
|
773
|
-
tool: 'sap_create_object',
|
|
774
|
-
args: { name: args.name, type: args.type, package: args.package },
|
|
775
|
-
started_at: new Date().toISOString(),
|
|
776
|
-
});
|
|
777
|
-
// ── §10a: a new package and its first transport, under this ONE approval ─
|
|
778
|
-
let devcExtras = {};
|
|
779
|
-
let devcTransportCreated;
|
|
780
|
-
if (isDevc) {
|
|
781
|
-
let flow;
|
|
782
|
-
try {
|
|
783
|
-
flow = await prepareNewPackage(args, ctx);
|
|
784
|
-
}
|
|
785
|
-
catch (err) {
|
|
786
|
-
// A throw (network, auth, …) must still close the pending entry and
|
|
787
|
-
// reach the person in plain words, never as a raw exception.
|
|
788
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
789
|
-
const answered = typeof err?.status === 'number';
|
|
790
|
-
const message = answered
|
|
791
|
-
? `SAP refused to create the transport: ${msg}`
|
|
792
|
-
: `Could not reach SAP to create the transport: ${msg}`;
|
|
793
|
-
await finalizeToolCall(ctx.session, toolUseId, message, true, Date.now() - startedAt);
|
|
794
|
-
return {
|
|
795
|
-
content: JSON.stringify({ error: 'transport_create_failed', message, next: 'Next: check the connection to SAP and try again.' }),
|
|
796
|
-
is_error: true,
|
|
797
|
-
};
|
|
798
|
-
}
|
|
799
|
-
if ('refused' in flow) {
|
|
800
|
-
await finalizeToolCall(ctx.session, toolUseId, flow.refused.message, true, Date.now() - startedAt);
|
|
801
|
-
return { content: JSON.stringify(flow.refused), is_error: true };
|
|
802
|
-
}
|
|
803
|
-
effectiveTransport = flow.corrNr;
|
|
804
|
-
devcTransportCreated = flow.created;
|
|
805
|
-
if (flow.created) {
|
|
806
|
-
// Same auto-attach as sap_transport_create: later creates in the new
|
|
807
|
-
// package pick this request up through the 3-tier fallback.
|
|
808
|
-
setCurrentTransport(flow.created);
|
|
809
|
-
markTransportCreatedThisSession(flow.created);
|
|
810
|
-
}
|
|
811
|
-
devcExtras = {
|
|
812
|
-
softwareComponent: args.softwareComponent,
|
|
813
|
-
transportLayer: flow.layer,
|
|
814
|
-
applicationComponent: args.applicationComponent,
|
|
815
|
-
superPackage: args.superPackage ? String(args.superPackage).toUpperCase() : undefined,
|
|
816
|
-
responsible: args.responsible,
|
|
817
|
-
};
|
|
818
|
-
}
|
|
819
|
-
// ── Create ───────────────────────────────────────────────────────────────
|
|
820
|
-
// createObject(objtype, name, packageName, description, transport?, extras?)
|
|
821
|
-
let result;
|
|
822
|
-
try {
|
|
823
|
-
result = await ctx.adt.createObject(args.type, args.name, args.package, args.description, effectiveTransport, {
|
|
824
|
-
serviceDefinition: args.serviceDefinition,
|
|
825
|
-
bindingType: args.bindingType,
|
|
826
|
-
bindingVersion: args.bindingVersion,
|
|
827
|
-
...devcExtras,
|
|
828
|
-
// Review fix round 1, I-1: a sap_lock handle lives in the stateful
|
|
829
|
-
// session; sap-client ends that session before a create unless told.
|
|
830
|
-
keepSession: hasManualLocks(),
|
|
831
|
-
});
|
|
832
|
-
await auditLog('createObject', args.type, args.name, 'success', Date.now() - startedAt);
|
|
833
|
-
}
|
|
834
|
-
catch (err) {
|
|
835
|
-
const errStr = String(err);
|
|
836
|
-
await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
|
|
837
|
-
return {
|
|
838
|
-
content: JSON.stringify({ error: 'create_failed', detail: errStr, ...orphanRequest(devcTransportCreated) }),
|
|
839
|
-
is_error: true,
|
|
840
|
-
};
|
|
841
|
-
}
|
|
842
|
-
if (result.status !== 'created') {
|
|
843
|
-
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
|
|
844
|
-
return {
|
|
845
|
-
content: JSON.stringify({ error: 'create_failed', detail: result.error, ...orphanRequest(devcTransportCreated) }),
|
|
846
|
-
is_error: true,
|
|
847
|
-
};
|
|
848
|
-
}
|
|
849
|
-
// F-L2-1 statusline wire. This path never reaches resolveWriteTransport —
|
|
850
|
-
// the 3-tier effectiveTransport above is what SAP stamped.
|
|
851
|
-
noteWriteTransport(effectiveTransport);
|
|
852
|
-
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), false, Date.now() - startedAt);
|
|
853
|
-
if (isDevc) {
|
|
854
|
-
const pkgName = result.objectName || String(args.name).toUpperCase();
|
|
855
|
-
return {
|
|
856
|
-
content: JSON.stringify({
|
|
857
|
-
created: true,
|
|
858
|
-
object: pkgName,
|
|
859
|
-
type: 'DEVC',
|
|
860
|
-
...(effectiveTransport ? { transport: effectiveTransport } : {}),
|
|
861
|
-
...(devcTransportCreated ? { transport_created: true } : {}),
|
|
862
|
-
message: effectiveTransport
|
|
863
|
-
? `Created package ${pkgName} on request ${effectiveTransport}.`
|
|
864
|
-
: `Created package ${pkgName} (no transport needed).`,
|
|
865
|
-
next_step: 'create_objects_in_the_package_on_this_transport',
|
|
866
|
-
}),
|
|
867
|
-
};
|
|
868
|
-
}
|
|
869
|
-
return {
|
|
870
|
-
content: JSON.stringify({
|
|
871
|
-
created: true,
|
|
872
|
-
object: result.objectName,
|
|
873
|
-
type: result.objectType,
|
|
874
|
-
package: result.packageName,
|
|
875
|
-
// A message class has no source and is never activated: its messages
|
|
876
|
-
// go in through sap_message_maintain (bug 3, 2026-10-12).
|
|
877
|
-
next_step: String(result.objectType ?? '').toUpperCase() === 'MSAG'
|
|
878
|
-
? 'add_messages_via_sap_message_maintain (a message class has no source and no activation)'
|
|
879
|
-
: 'write_source_via_sap_set_source_then_activate',
|
|
880
|
-
}),
|
|
881
|
-
};
|
|
421
|
+
},
|
|
422
|
+
required: ['name', 'type', 'package', 'description', 'approval_id'],
|
|
882
423
|
},
|
|
424
|
+
handler: writeToolHandler(CREATE_OBJECT_SPEC),
|
|
883
425
|
});
|
|
884
426
|
/**
|
|
885
427
|
* §10a — when the package create fails after the flow already made a request,
|
|
@@ -965,16 +507,54 @@ async function prepareNewPackage(args, ctx) {
|
|
|
965
507
|
return { refused: reusedRequestRefusal(tr) };
|
|
966
508
|
return { corrNr: tr.transportNumber, layer, created: tr.transportNumber };
|
|
967
509
|
}
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
510
|
+
const CLASS_INCLUDE_SPEC = {
|
|
511
|
+
tool: 'sap_set_class_include',
|
|
512
|
+
approval: {
|
|
513
|
+
object: (a) => a.className,
|
|
514
|
+
op: 'modify',
|
|
515
|
+
claims: (a, ctx) => ({ type: 'CLAS', transport: a.transport ?? ctx.currentTransport?.get() ?? null }),
|
|
516
|
+
},
|
|
517
|
+
transport: { mode: 'fallback-resolve', type: () => 'CLAS', name: (a) => a.className, grantMismatch: true },
|
|
518
|
+
snapshot: { kind: 'include', className: (a) => a.className, includeType: (a) => a.includeType },
|
|
519
|
+
walArgs: (a) => ({ className: a.className, includeType: a.includeType }),
|
|
520
|
+
// setClassInclude THROWS only when it cannot acquire the class lock; for the
|
|
521
|
+
// all-variants-failed case it RETURNS { written:false, ... } (landed).
|
|
522
|
+
call: (a, ctx, pre) => ctx.adt.setClassInclude(a.className, a.includeType, a.source, pre.transport),
|
|
523
|
+
errorCode: 'class_include_write_failed',
|
|
524
|
+
// D14: `error` carries SAP's T100 diagnosis verbatim when a variant hit a
|
|
525
|
+
// CTS/lock conflict — passed through unaltered, plus the owning transport so
|
|
526
|
+
// the model can retry correctly instead of declaring the change manual-only.
|
|
527
|
+
landed: (r) => (r.written
|
|
528
|
+
? { ok: true }
|
|
529
|
+
: {
|
|
530
|
+
ok: false,
|
|
531
|
+
detail: r.error ?? `All PUT variants failed (last HTTP ${r.httpStatus})`,
|
|
532
|
+
log: JSON.stringify(r),
|
|
533
|
+
extra: {
|
|
534
|
+
http_status: r.httpStatus,
|
|
535
|
+
...(r.lockedInTransport ? {
|
|
536
|
+
locked_in_transport: r.lockedInTransport,
|
|
537
|
+
recovery: `Retry this exact write with transport=${r.lockedInTransport} — that request already owns the object.`,
|
|
538
|
+
} : {}),
|
|
539
|
+
attempts: r.attempts,
|
|
540
|
+
},
|
|
541
|
+
}),
|
|
542
|
+
audit: { method: 'setClassInclude', type: () => 'CLAS', name: (a) => a.className, detail: (a) => `include=${a.includeType}` },
|
|
543
|
+
verify: { syntax: false, activation: false },
|
|
544
|
+
walResult: (_a, r) => ({ written: true, variant: r.variantUsed }),
|
|
545
|
+
chain: (a, pre) => ({
|
|
546
|
+
...snapshotChainState(pre.snapshot),
|
|
547
|
+
written: true,
|
|
548
|
+
syntax: 'not_checked',
|
|
549
|
+
card: { object: a.className, what: `include ${a.includeType}`, transport: pre.transport },
|
|
550
|
+
}),
|
|
551
|
+
envelope: (_a, r, pre) => ({
|
|
552
|
+
written: true,
|
|
553
|
+
variant: r.variantUsed,
|
|
554
|
+
...(pre.owning ? { transport_used: pre.owning, transport_note: pre.note } : {}),
|
|
555
|
+
next_step: 'activate_the_class',
|
|
556
|
+
}),
|
|
557
|
+
};
|
|
978
558
|
registerTool({
|
|
979
559
|
name: 'sap_set_class_include',
|
|
980
560
|
description: 'Write a class local-include source (CCDEF locals_def, CCIMP locals_imp, '
|
|
@@ -999,132 +579,81 @@ registerTool({
|
|
|
999
579
|
},
|
|
1000
580
|
required: ['className', 'includeType', 'source', 'approval_id'],
|
|
1001
581
|
},
|
|
1002
|
-
handler:
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
|
|
1008
|
-
is_error: true,
|
|
1009
|
-
};
|
|
1010
|
-
}
|
|
582
|
+
handler: writeToolHandler(CLASS_INCLUDE_SPEC),
|
|
583
|
+
});
|
|
584
|
+
function ddicCreateSpec(tool, type, auditType, create) {
|
|
585
|
+
return {
|
|
586
|
+
tool,
|
|
1011
587
|
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
1012
|
-
{
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
588
|
+
approval: {
|
|
589
|
+
object: (a) => a.name, op: 'create',
|
|
590
|
+
claims: (a, ctx) => ({ type, package: a.package, transport: a.transport ?? ctx.currentTransport?.get() ?? null }),
|
|
591
|
+
},
|
|
592
|
+
transport: { mode: 'three-tier' },
|
|
593
|
+
snapshot: { kind: 'none', reason: 'a new object has no prior source' },
|
|
594
|
+
walArgs: (a) => ({ name: a.name, type, package: a.package }),
|
|
595
|
+
call: async (a, ctx, pre) => {
|
|
596
|
+
const r = await create(a, ctx, pre.transport);
|
|
597
|
+
// F-L2-1 statusline wire, recorded on CREATED, not on active: a
|
|
598
|
+
// create-succeeded / activation-failed run must still show which request
|
|
599
|
+
// holds the object. The pipeline notes a landed create itself; this one
|
|
600
|
+
// is a miss there.
|
|
601
|
+
if (r.created && !r.active)
|
|
602
|
+
noteWriteTransport(pre.transport);
|
|
603
|
+
return r;
|
|
604
|
+
},
|
|
605
|
+
errorCode: 'create_failed',
|
|
606
|
+
landed: (r) => {
|
|
607
|
+
if (!r.created)
|
|
608
|
+
return { ok: false, detail: r.error, log: JSON.stringify(r) };
|
|
609
|
+
// Rule 10: created but did not activate — surface as an error.
|
|
610
|
+
// Final review I-2: the object now exists in SAP, so the audit log says so.
|
|
611
|
+
if (!r.active) {
|
|
612
|
+
return {
|
|
613
|
+
ok: false, detail: r.error, log: JSON.stringify(r), body: { error: 'activation_failed', object: r.name, detail: r.error },
|
|
614
|
+
audit: { status: 'error', reason: `created in SAP but not activated${r.error ? `: ${r.error}` : ''}` },
|
|
615
|
+
};
|
|
1024
616
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
});
|
|
1063
|
-
// ── Write ────────────────────────────────────────────────────────────────
|
|
1064
|
-
// setClassInclude THROWS only when it cannot acquire the class lock;
|
|
1065
|
-
// for the all-variants-failed case it RETURNS { written:false, ... }.
|
|
1066
|
-
// Both paths must surface as an error envelope.
|
|
1067
|
-
let result;
|
|
1068
|
-
try {
|
|
1069
|
-
result = await ctx.adt.setClassInclude(args.className, args.includeType, args.source, resolved.transport);
|
|
1070
|
-
}
|
|
1071
|
-
catch (err) {
|
|
1072
|
-
const errStr = String(err);
|
|
1073
|
-
await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
|
|
1074
|
-
return {
|
|
1075
|
-
content: JSON.stringify({ error: 'class_include_write_failed', detail: errStr }),
|
|
1076
|
-
is_error: true,
|
|
1077
|
-
};
|
|
1078
|
-
}
|
|
1079
|
-
if (!result.written) {
|
|
1080
|
-
// D14: `result.error` now carries SAP's T100 diagnosis verbatim when a
|
|
1081
|
-
// variant hit a CTS/lock conflict — pass it through unaltered, plus the
|
|
1082
|
-
// structured owning transport so the model can retry correctly instead
|
|
1083
|
-
// of declaring the change manual-only.
|
|
1084
|
-
const detail = result.error ?? `All PUT variants failed (last HTTP ${result.httpStatus})`;
|
|
1085
|
-
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
|
|
1086
|
-
return {
|
|
1087
|
-
content: JSON.stringify({
|
|
1088
|
-
error: 'class_include_write_failed',
|
|
1089
|
-
detail,
|
|
1090
|
-
http_status: result.httpStatus,
|
|
1091
|
-
...(result.lockedInTransport ? {
|
|
1092
|
-
locked_in_transport: result.lockedInTransport,
|
|
1093
|
-
recovery: `Retry this exact write with transport=${result.lockedInTransport} — that request already owns the object.`,
|
|
1094
|
-
} : {}),
|
|
1095
|
-
attempts: result.attempts,
|
|
1096
|
-
}),
|
|
1097
|
-
is_error: true,
|
|
1098
|
-
};
|
|
1099
|
-
}
|
|
1100
|
-
await auditLog('setClassInclude', 'CLAS', args.className, 'success', Date.now() - startedAt, `include=${args.includeType}`);
|
|
1101
|
-
noteWriteTransport(resolved.transport); // F-L2-1 statusline wire
|
|
1102
|
-
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ written: true, variant: result.variantUsed }), false, Date.now() - startedAt);
|
|
1103
|
-
// Tour slide-7 chain. Honest states: includes have no standalone syntax
|
|
1104
|
-
// gate (checked at activation), so ③ renders as deferred, never faked.
|
|
1105
|
-
emitWriteChain(ctx, {
|
|
1106
|
-
...snapshotChainState(snapOutcome),
|
|
1107
|
-
written: true,
|
|
1108
|
-
syntax: 'not_checked',
|
|
1109
|
-
card: { object: args.className, what: `include ${args.includeType}`, transport: resolved.transport },
|
|
1110
|
-
});
|
|
1111
|
-
return {
|
|
1112
|
-
content: JSON.stringify({
|
|
1113
|
-
written: true,
|
|
1114
|
-
variant: result.variantUsed,
|
|
1115
|
-
...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
|
|
1116
|
-
next_step: 'activate_the_class',
|
|
1117
|
-
}),
|
|
1118
|
-
};
|
|
1119
|
-
},
|
|
1120
|
-
});
|
|
1121
|
-
// ── sap_create_domain ────────────────────────────────────────────────────────
|
|
1122
|
-
//
|
|
1123
|
-
// Signature: createDomain(spec, transport?) → Promise<DdicCreateResult>
|
|
1124
|
-
// AdtClient creates → activates → verifies in one call (Rule 10 internal).
|
|
1125
|
-
// No snapshot needed: new object has no prior source.
|
|
617
|
+
return { ok: true };
|
|
618
|
+
},
|
|
619
|
+
audit: { method: 'createObject', type: () => auditType, name: (a) => a.name },
|
|
620
|
+
verify: { syntax: false, activation: false },
|
|
621
|
+
walResult: (_a, r) => r,
|
|
622
|
+
envelope: (_a, r) => ({
|
|
623
|
+
created: true,
|
|
624
|
+
active: true,
|
|
625
|
+
object: r.name,
|
|
626
|
+
type: r.type,
|
|
627
|
+
next_step: 'use_in_table_field',
|
|
628
|
+
}),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
const DOMAIN_HANDLER = writeToolHandler(ddicCreateSpec('sap_create_domain', 'DOMA', 'DOMA/DD', (args, ctx, transport) => {
|
|
632
|
+
// Build spec (omit undefined optionals).
|
|
633
|
+
const spec = {
|
|
634
|
+
name: args.name,
|
|
635
|
+
description: args.description,
|
|
636
|
+
package: args.package,
|
|
637
|
+
dataType: args.dataType,
|
|
638
|
+
length: args.length,
|
|
639
|
+
};
|
|
640
|
+
if (args.decimals !== undefined)
|
|
641
|
+
spec.decimals = args.decimals;
|
|
642
|
+
if (args.outputLength !== undefined)
|
|
643
|
+
spec.outputLength = args.outputLength;
|
|
644
|
+
if (args.lowercase !== undefined)
|
|
645
|
+
spec.lowercase = args.lowercase;
|
|
646
|
+
if (args.signExists !== undefined)
|
|
647
|
+
spec.signExists = args.signExists;
|
|
648
|
+
if (args.valueTable !== undefined)
|
|
649
|
+
spec.valueTable = args.valueTable;
|
|
650
|
+
if (args.fixedValues !== undefined)
|
|
651
|
+
spec.fixedValues = args.fixedValues;
|
|
652
|
+
return ctx.adt.createDomain(spec, transport);
|
|
653
|
+
}));
|
|
1126
654
|
registerTool({
|
|
1127
655
|
name: 'sap_create_domain',
|
|
656
|
+
requires: 'ddic.domains',
|
|
1128
657
|
description: 'Create a DDIC domain (DOMA), then activate and verify it. '
|
|
1129
658
|
+ 'Requires approval_id. No snapshot needed (new object). '
|
|
1130
659
|
+ 'Use the domain in a data element via sap_create_data_element.',
|
|
@@ -1160,112 +689,30 @@ registerTool({
|
|
|
1160
689
|
},
|
|
1161
690
|
required: ['name', 'description', 'package', 'dataType', 'length', 'approval_id'],
|
|
1162
691
|
},
|
|
1163
|
-
handler:
|
|
1164
|
-
// ── Gate 1: Approval ────────────────────────────────────────────────────
|
|
1165
|
-
const verify = await verifyAndSpendApprovalId(args.approval_id, args.name, 'create');
|
|
1166
|
-
if (!verify.ok) {
|
|
1167
|
-
return {
|
|
1168
|
-
content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
|
|
1169
|
-
is_error: true,
|
|
1170
|
-
};
|
|
1171
|
-
}
|
|
1172
|
-
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
1173
|
-
{
|
|
1174
|
-
const claim = grantClaimMismatch(verify.payload, { type: 'DOMA', package: args.package, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
|
|
1175
|
-
if (claim)
|
|
1176
|
-
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
1177
|
-
}
|
|
1178
|
-
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
1179
|
-
const startedAt = Date.now();
|
|
1180
|
-
// No snapshot — new object has no prior source.
|
|
1181
|
-
// ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
|
|
1182
|
-
{
|
|
1183
|
-
const sessionTransport = ctx.currentTransport?.get() ?? null;
|
|
1184
|
-
if (args.transport && sessionTransport && args.transport !== sessionTransport) {
|
|
1185
|
-
ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
const effectiveTransport = args.transport ?? ctx.currentTransport?.get() ?? undefined;
|
|
1189
|
-
// ── WAL: record pending entry ────────────────────────────────────────────
|
|
1190
|
-
await appendPending(ctx.session, {
|
|
1191
|
-
tool_use_id: toolUseId,
|
|
1192
|
-
tool: 'sap_create_domain',
|
|
1193
|
-
args: { name: args.name, type: 'DOMA', package: args.package },
|
|
1194
|
-
started_at: new Date().toISOString(),
|
|
1195
|
-
});
|
|
1196
|
-
// ── Build spec (omit undefined optionals) ────────────────────────────────
|
|
1197
|
-
const spec = {
|
|
1198
|
-
name: args.name,
|
|
1199
|
-
description: args.description,
|
|
1200
|
-
package: args.package,
|
|
1201
|
-
dataType: args.dataType,
|
|
1202
|
-
length: args.length,
|
|
1203
|
-
};
|
|
1204
|
-
if (args.decimals !== undefined)
|
|
1205
|
-
spec.decimals = args.decimals;
|
|
1206
|
-
if (args.outputLength !== undefined)
|
|
1207
|
-
spec.outputLength = args.outputLength;
|
|
1208
|
-
if (args.lowercase !== undefined)
|
|
1209
|
-
spec.lowercase = args.lowercase;
|
|
1210
|
-
if (args.signExists !== undefined)
|
|
1211
|
-
spec.signExists = args.signExists;
|
|
1212
|
-
if (args.valueTable !== undefined)
|
|
1213
|
-
spec.valueTable = args.valueTable;
|
|
1214
|
-
if (args.fixedValues !== undefined)
|
|
1215
|
-
spec.fixedValues = args.fixedValues;
|
|
1216
|
-
// ── Create → activate → verify (AdtClient does all three) ────────────────
|
|
1217
|
-
let result;
|
|
1218
|
-
try {
|
|
1219
|
-
result = await ctx.adt.createDomain(spec, effectiveTransport);
|
|
1220
|
-
await auditLog('createObject', 'DOMA/DD', args.name, 'success', Date.now() - startedAt);
|
|
1221
|
-
}
|
|
1222
|
-
catch (err) {
|
|
1223
|
-
const errStr = String(err);
|
|
1224
|
-
await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
|
|
1225
|
-
return {
|
|
1226
|
-
content: JSON.stringify({ error: 'create_failed', detail: errStr }),
|
|
1227
|
-
is_error: true,
|
|
1228
|
-
};
|
|
1229
|
-
}
|
|
1230
|
-
if (!result.created) {
|
|
1231
|
-
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
|
|
1232
|
-
return {
|
|
1233
|
-
content: JSON.stringify({ error: 'create_failed', detail: result.error }),
|
|
1234
|
-
is_error: true,
|
|
1235
|
-
};
|
|
1236
|
-
}
|
|
1237
|
-
// F-L2-1 statusline wire. Recorded on CREATED, not on active: the object is
|
|
1238
|
-
// already in effectiveTransport by then, so a create-succeeded /
|
|
1239
|
-
// activation-failed run must still show which request holds it. DDIC creates
|
|
1240
|
-
// bypass resolveWriteTransport — effectiveTransport is what SAP stamped.
|
|
1241
|
-
noteWriteTransport(effectiveTransport);
|
|
1242
|
-
if (!result.active) {
|
|
1243
|
-
// Rule 10: created but did not activate — surface as an error.
|
|
1244
|
-
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
|
|
1245
|
-
return {
|
|
1246
|
-
content: JSON.stringify({ error: 'activation_failed', object: result.name, detail: result.error }),
|
|
1247
|
-
is_error: true,
|
|
1248
|
-
};
|
|
1249
|
-
}
|
|
1250
|
-
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), false, Date.now() - startedAt);
|
|
1251
|
-
return {
|
|
1252
|
-
content: JSON.stringify({
|
|
1253
|
-
created: true,
|
|
1254
|
-
active: true,
|
|
1255
|
-
object: result.name,
|
|
1256
|
-
type: result.type,
|
|
1257
|
-
next_step: 'use_in_table_field',
|
|
1258
|
-
}),
|
|
1259
|
-
};
|
|
1260
|
-
},
|
|
692
|
+
handler: DOMAIN_HANDLER,
|
|
1261
693
|
});
|
|
1262
|
-
|
|
1263
|
-
//
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
694
|
+
const DATA_ELEMENT_HANDLER = writeToolHandler(ddicCreateSpec('sap_create_data_element', 'DTEL', 'DTEL/DE', (args, ctx, transport) => {
|
|
695
|
+
// Build spec (omit undefined optionals).
|
|
696
|
+
const spec = {
|
|
697
|
+
name: args.name,
|
|
698
|
+
description: args.description,
|
|
699
|
+
package: args.package,
|
|
700
|
+
domainName: args.domainName,
|
|
701
|
+
dataType: args.dataType,
|
|
702
|
+
length: args.length,
|
|
703
|
+
labels: args.labels,
|
|
704
|
+
};
|
|
705
|
+
if (args.decimals !== undefined)
|
|
706
|
+
spec.decimals = args.decimals;
|
|
707
|
+
if (args.searchHelp !== undefined)
|
|
708
|
+
spec.searchHelp = args.searchHelp;
|
|
709
|
+
if (args.searchHelpParameter !== undefined)
|
|
710
|
+
spec.searchHelpParameter = args.searchHelpParameter;
|
|
711
|
+
return ctx.adt.createDataElement(spec, transport);
|
|
712
|
+
}));
|
|
1267
713
|
registerTool({
|
|
1268
714
|
name: 'sap_create_data_element',
|
|
715
|
+
requires: 'ddic.dataelements',
|
|
1269
716
|
description: 'Create a DDIC data element (DTEL) bound to an existing domain, then '
|
|
1270
717
|
+ 'activate and verify it. Requires approval_id. No snapshot needed (new object). '
|
|
1271
718
|
+ 'Use the data element as a table/structure field type.',
|
|
@@ -1299,97 +746,7 @@ registerTool({
|
|
|
1299
746
|
},
|
|
1300
747
|
required: ['name', 'description', 'package', 'domainName', 'dataType', 'length', 'labels', 'approval_id'],
|
|
1301
748
|
},
|
|
1302
|
-
handler:
|
|
1303
|
-
// ── Gate 1: Approval ────────────────────────────────────────────────────
|
|
1304
|
-
const verify = await verifyAndSpendApprovalId(args.approval_id, args.name, 'create');
|
|
1305
|
-
if (!verify.ok) {
|
|
1306
|
-
return {
|
|
1307
|
-
content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
|
|
1308
|
-
is_error: true,
|
|
1309
|
-
};
|
|
1310
|
-
}
|
|
1311
|
-
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
1312
|
-
{
|
|
1313
|
-
const claim = grantClaimMismatch(verify.payload, { type: 'DTEL', package: args.package, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
|
|
1314
|
-
if (claim)
|
|
1315
|
-
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
1316
|
-
}
|
|
1317
|
-
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
1318
|
-
const startedAt = Date.now();
|
|
1319
|
-
// No snapshot — new object has no prior source.
|
|
1320
|
-
// ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
|
|
1321
|
-
{
|
|
1322
|
-
const sessionTransport = ctx.currentTransport?.get() ?? null;
|
|
1323
|
-
if (args.transport && sessionTransport && args.transport !== sessionTransport) {
|
|
1324
|
-
ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
const effectiveTransport = args.transport ?? ctx.currentTransport?.get() ?? undefined;
|
|
1328
|
-
// ── WAL: record pending entry ────────────────────────────────────────────
|
|
1329
|
-
await appendPending(ctx.session, {
|
|
1330
|
-
tool_use_id: toolUseId,
|
|
1331
|
-
tool: 'sap_create_data_element',
|
|
1332
|
-
args: { name: args.name, type: 'DTEL', package: args.package },
|
|
1333
|
-
started_at: new Date().toISOString(),
|
|
1334
|
-
});
|
|
1335
|
-
// ── Build spec (omit undefined optionals) ────────────────────────────────
|
|
1336
|
-
const spec = {
|
|
1337
|
-
name: args.name,
|
|
1338
|
-
description: args.description,
|
|
1339
|
-
package: args.package,
|
|
1340
|
-
domainName: args.domainName,
|
|
1341
|
-
dataType: args.dataType,
|
|
1342
|
-
length: args.length,
|
|
1343
|
-
labels: args.labels,
|
|
1344
|
-
};
|
|
1345
|
-
if (args.decimals !== undefined)
|
|
1346
|
-
spec.decimals = args.decimals;
|
|
1347
|
-
if (args.searchHelp !== undefined)
|
|
1348
|
-
spec.searchHelp = args.searchHelp;
|
|
1349
|
-
if (args.searchHelpParameter !== undefined)
|
|
1350
|
-
spec.searchHelpParameter = args.searchHelpParameter;
|
|
1351
|
-
// ── Create → activate → verify (AdtClient does all three) ────────────────
|
|
1352
|
-
let result;
|
|
1353
|
-
try {
|
|
1354
|
-
result = await ctx.adt.createDataElement(spec, effectiveTransport);
|
|
1355
|
-
await auditLog('createObject', 'DTEL/DE', args.name, 'success', Date.now() - startedAt);
|
|
1356
|
-
}
|
|
1357
|
-
catch (err) {
|
|
1358
|
-
const errStr = String(err);
|
|
1359
|
-
await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
|
|
1360
|
-
return {
|
|
1361
|
-
content: JSON.stringify({ error: 'create_failed', detail: errStr }),
|
|
1362
|
-
is_error: true,
|
|
1363
|
-
};
|
|
1364
|
-
}
|
|
1365
|
-
if (!result.created) {
|
|
1366
|
-
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
|
|
1367
|
-
return {
|
|
1368
|
-
content: JSON.stringify({ error: 'create_failed', detail: result.error }),
|
|
1369
|
-
is_error: true,
|
|
1370
|
-
};
|
|
1371
|
-
}
|
|
1372
|
-
// F-L2-1 statusline wire — see the createDomain note above (created, not active).
|
|
1373
|
-
noteWriteTransport(effectiveTransport);
|
|
1374
|
-
if (!result.active) {
|
|
1375
|
-
// Rule 10: created but did not activate — surface as an error.
|
|
1376
|
-
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
|
|
1377
|
-
return {
|
|
1378
|
-
content: JSON.stringify({ error: 'activation_failed', object: result.name, detail: result.error }),
|
|
1379
|
-
is_error: true,
|
|
1380
|
-
};
|
|
1381
|
-
}
|
|
1382
|
-
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), false, Date.now() - startedAt);
|
|
1383
|
-
return {
|
|
1384
|
-
content: JSON.stringify({
|
|
1385
|
-
created: true,
|
|
1386
|
-
active: true,
|
|
1387
|
-
object: result.name,
|
|
1388
|
-
type: result.type,
|
|
1389
|
-
next_step: 'use_in_table_field',
|
|
1390
|
-
}),
|
|
1391
|
-
};
|
|
1392
|
-
},
|
|
749
|
+
handler: DATA_ELEMENT_HANDLER,
|
|
1393
750
|
});
|
|
1394
751
|
// ── sap_activate ─────────────────────────────────────────────────────────────
|
|
1395
752
|
//
|
|
@@ -1617,6 +974,7 @@ registerTool({
|
|
|
1617
974
|
// Signature: serviceBindingPublish(name) → Promise<{ status: number; error?: string }>
|
|
1618
975
|
registerTool({
|
|
1619
976
|
name: 'sap_service_binding_publish',
|
|
977
|
+
requires: 'rap.srvb',
|
|
1620
978
|
description: 'Publish an ADT service binding so its OData endpoint becomes active and reachable.',
|
|
1621
979
|
isMutating: true,
|
|
1622
980
|
requiresSap: true,
|
|
@@ -1640,6 +998,7 @@ registerTool({
|
|
|
1640
998
|
// NOTE: the unpublishjobs endpoint is inferred-not-yet-live-verified (VPN down).
|
|
1641
999
|
registerTool({
|
|
1642
1000
|
name: 'sap_service_binding_unpublish',
|
|
1001
|
+
requires: 'rap.srvb',
|
|
1643
1002
|
description: 'Unpublish an ADT service binding so its OData endpoint is removed. '
|
|
1644
1003
|
+ 'Required before deleting a published binding (SAP returns HTTP 412 "is published" otherwise).',
|
|
1645
1004
|
isMutating: true,
|
|
@@ -1711,8 +1070,120 @@ function messageRowsProblem(rows) {
|
|
|
1711
1070
|
}
|
|
1712
1071
|
return null;
|
|
1713
1072
|
}
|
|
1073
|
+
const msagThrowText = (err) => (err instanceof Error ? err.message : String(err));
|
|
1074
|
+
const msagName = (a) => String(a.messageClass ?? '').trim().toUpperCase();
|
|
1075
|
+
const msagRows = (a) => messageRowsFromArgs(a);
|
|
1076
|
+
const msagLockNote = (pre) => pre.extra['lock_note'];
|
|
1077
|
+
const MESSAGE_MAINTAIN_SPEC = {
|
|
1078
|
+
tool: 'sap_message_maintain',
|
|
1079
|
+
beforeApproval: (a, ctx) => {
|
|
1080
|
+
// Input check — before the approval is spent.
|
|
1081
|
+
const className = msagName(a);
|
|
1082
|
+
const problem = !className
|
|
1083
|
+
? 'Give the message class name.'
|
|
1084
|
+
: !MESSAGE_CLASS_NAME_RE.test(className)
|
|
1085
|
+
? `"${String(a.messageClass)}" is not a valid message class name (letters, digits and _ only, 20 characters at most).`
|
|
1086
|
+
: messageRowsProblem(msagRows(a));
|
|
1087
|
+
if (problem)
|
|
1088
|
+
return { error: 'invalid_messages', detail: problem };
|
|
1089
|
+
// Capability — before the approval is spent (review I-1; an old sap-client pin).
|
|
1090
|
+
const adt = ctx.adt;
|
|
1091
|
+
if (typeof adt.readMessageClass !== 'function' || typeof adt.maintainMessages !== 'function') {
|
|
1092
|
+
return { error: 'message_maintain_unavailable', detail: 'This CSPeach build cannot write messages yet. Update CSPeach and try again.' };
|
|
1093
|
+
}
|
|
1094
|
+
return undefined;
|
|
1095
|
+
},
|
|
1096
|
+
approval: {
|
|
1097
|
+
object: (a) => a.messageClass,
|
|
1098
|
+
op: 'modify',
|
|
1099
|
+
claims: (a, ctx) => ({ type: 'MSAG', transport: a.transport ?? ctx.currentTransport?.get() ?? null }),
|
|
1100
|
+
},
|
|
1101
|
+
transport: { mode: 'fallback-resolve', type: () => 'MSAG', name: msagName, grantMismatch: true },
|
|
1102
|
+
snapshot: { kind: 'messages', name: msagName },
|
|
1103
|
+
walArgs: (a) => ({ messageClass: msagName(a), messages: msagRows(a).map((r) => r.msgno) }),
|
|
1104
|
+
// sap-client locks, PUTs, unlocks in a finally and reads back. A lock it may
|
|
1105
|
+
// have left is said right after the call, on success and failure (review M-1).
|
|
1106
|
+
call: async (a, ctx, pre) => {
|
|
1107
|
+
const r = await ctx.adt.maintainMessages({
|
|
1108
|
+
name: msagName(a),
|
|
1109
|
+
messages: msagRows(a),
|
|
1110
|
+
...(pre.transport ? { transport: pre.transport } : {}),
|
|
1111
|
+
});
|
|
1112
|
+
const lockNote = lockWarning(msagName(a), r);
|
|
1113
|
+
pre.extra['lock_note'] = lockNote;
|
|
1114
|
+
if (lockNote)
|
|
1115
|
+
ctx.chunkEmitter?.emit('warn', lockNote);
|
|
1116
|
+
return r;
|
|
1117
|
+
},
|
|
1118
|
+
// One source for the thrown text: the envelope detail and the WAL log say the same (final review M-4).
|
|
1119
|
+
onThrow: (err) => ({ error: 'message_maintain_failed', detail: msagThrowText(err) }),
|
|
1120
|
+
throwLog: msagThrowText,
|
|
1121
|
+
errorCode: 'message_maintain_failed',
|
|
1122
|
+
// Success is SAP's read-back (`verified`), never HTTP 200.
|
|
1123
|
+
landed: (r, pre) => {
|
|
1124
|
+
if (r.verified)
|
|
1125
|
+
return { ok: true };
|
|
1126
|
+
const lockNote = msagLockNote(pre);
|
|
1127
|
+
const detail = r.error ?? 'The messages could not be confirmed in SAP.';
|
|
1128
|
+
return {
|
|
1129
|
+
ok: false,
|
|
1130
|
+
detail,
|
|
1131
|
+
log: JSON.stringify(r),
|
|
1132
|
+
body: {
|
|
1133
|
+
error: 'message_maintain_failed',
|
|
1134
|
+
...(lockNote ? { lock_warning: lockNote } : {}),
|
|
1135
|
+
detail,
|
|
1136
|
+
missing: r.missing,
|
|
1137
|
+
...(r.httpStatus !== undefined ? { http_status: r.httpStatus } : {}),
|
|
1138
|
+
...(r.lockedInTransport ? {
|
|
1139
|
+
locked_in_transport: r.lockedInTransport,
|
|
1140
|
+
recovery: `Retry this exact call with transport=${r.lockedInTransport} — that request already owns the message class.`,
|
|
1141
|
+
} : {}),
|
|
1142
|
+
},
|
|
1143
|
+
};
|
|
1144
|
+
},
|
|
1145
|
+
// Controller ruling (Task 12 review I-1.4): the call landed, nothing was
|
|
1146
|
+
// written — WAL closed clean, NO audit row, is_error absent.
|
|
1147
|
+
noWrite: (a, r) => (r.noChange
|
|
1148
|
+
? {
|
|
1149
|
+
walResult: { unchanged: true },
|
|
1150
|
+
envelope: {
|
|
1151
|
+
written: false,
|
|
1152
|
+
unchanged: true,
|
|
1153
|
+
message_class: msagName(a),
|
|
1154
|
+
changes: r.changes,
|
|
1155
|
+
message: `Message class ${msagName(a)} already has these texts. Nothing was changed.`,
|
|
1156
|
+
},
|
|
1157
|
+
}
|
|
1158
|
+
: undefined),
|
|
1159
|
+
audit: {
|
|
1160
|
+
method: 'maintainMessages', type: () => 'MSAG', name: msagName,
|
|
1161
|
+
detail: (_a, r) => r.changes.map((c) => `${c.msgno}:${c.action}`).join(' '),
|
|
1162
|
+
},
|
|
1163
|
+
verify: { syntax: false, activation: false },
|
|
1164
|
+
walResult: (_a, r) => ({ written: true, verified: true, changes: r.changes }),
|
|
1165
|
+
after: (a, r, _pre, ctx) => {
|
|
1166
|
+
const written = r.changes.filter((c) => c.action !== 'unchanged').map((c) => c.msgno);
|
|
1167
|
+
ctx.chunkEmitter?.emit('info', `messages ✓ ${msagName(a)} ${written.join(' ')} (no activation needed)`);
|
|
1168
|
+
},
|
|
1169
|
+
envelope: (a, r, pre) => {
|
|
1170
|
+
const className = msagName(a);
|
|
1171
|
+
const lockNote = msagLockNote(pre);
|
|
1172
|
+
const first = r.changes.filter((c) => c.action !== 'unchanged').map((c) => c.msgno)[0] ?? msagRows(a)[0].msgno;
|
|
1173
|
+
return {
|
|
1174
|
+
written: true,
|
|
1175
|
+
verified: true,
|
|
1176
|
+
message_class: className,
|
|
1177
|
+
changes: r.changes,
|
|
1178
|
+
...(pre.owning ? { transport_used: pre.owning, transport_note: pre.note } : {}),
|
|
1179
|
+
...(lockNote ? { lock_warning: lockNote } : {}),
|
|
1180
|
+
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.`,
|
|
1181
|
+
};
|
|
1182
|
+
},
|
|
1183
|
+
};
|
|
1714
1184
|
registerTool({
|
|
1715
1185
|
name: 'sap_message_maintain',
|
|
1186
|
+
requires: 'msag',
|
|
1716
1187
|
description: 'Add or change messages in an existing message class (MSAG, the T100 texts behind SE91). '
|
|
1717
1188
|
+ 'Create the class first with sap_create_object type MSAG. One call takes many messages. '
|
|
1718
1189
|
+ 'Each message: msgno (three digits, 000-999) and text (up to 73 characters, &1-&4 placeholders allowed). '
|
|
@@ -1745,169 +1216,7 @@ registerTool({
|
|
|
1745
1216
|
},
|
|
1746
1217
|
required: ['messageClass', 'approval_id'],
|
|
1747
1218
|
},
|
|
1748
|
-
handler:
|
|
1749
|
-
const className = String(args.messageClass ?? '').trim().toUpperCase();
|
|
1750
|
-
// ── Input check — before the approval is spent ──────────────────────────
|
|
1751
|
-
const rows = messageRowsFromArgs(args);
|
|
1752
|
-
const problem = !className
|
|
1753
|
-
? 'Give the message class name.'
|
|
1754
|
-
: !MESSAGE_CLASS_NAME_RE.test(className)
|
|
1755
|
-
? `"${String(args.messageClass)}" is not a valid message class name (letters, digits and _ only, 20 characters at most).`
|
|
1756
|
-
: messageRowsProblem(rows);
|
|
1757
|
-
if (problem) {
|
|
1758
|
-
return { content: JSON.stringify({ error: 'invalid_messages', detail: problem }), is_error: true };
|
|
1759
|
-
}
|
|
1760
|
-
// ── Capability — before the approval is spent (review I-1) ──────────────
|
|
1761
|
-
const adt = ctx.adt;
|
|
1762
|
-
if (typeof adt.readMessageClass !== 'function' || typeof adt.maintainMessages !== 'function') {
|
|
1763
|
-
return {
|
|
1764
|
-
content: JSON.stringify({
|
|
1765
|
-
error: 'message_maintain_unavailable',
|
|
1766
|
-
detail: 'This CSPeach build cannot write messages yet. Update CSPeach and try again.',
|
|
1767
|
-
}),
|
|
1768
|
-
is_error: true,
|
|
1769
|
-
};
|
|
1770
|
-
}
|
|
1771
|
-
const msagClient = adt;
|
|
1772
|
-
// ── Gate 1: Approval ────────────────────────────────────────────────────
|
|
1773
|
-
const verify = await verifyAndSpendApprovalId(args.approval_id, args.messageClass, 'modify');
|
|
1774
|
-
if (!verify.ok) {
|
|
1775
|
-
return {
|
|
1776
|
-
content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
|
|
1777
|
-
is_error: true,
|
|
1778
|
-
};
|
|
1779
|
-
}
|
|
1780
|
-
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
1781
|
-
{
|
|
1782
|
-
const claim = grantClaimMismatch(verify.payload, { type: 'MSAG', transport: args.transport ?? ctx.currentTransport?.get() ?? null });
|
|
1783
|
-
if (claim)
|
|
1784
|
-
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
1785
|
-
}
|
|
1786
|
-
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
1787
|
-
const startedAt = Date.now();
|
|
1788
|
-
// ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
|
|
1789
|
-
{
|
|
1790
|
-
const sessionTransport = ctx.currentTransport?.get() ?? null;
|
|
1791
|
-
if (args.transport && sessionTransport && args.transport !== sessionTransport) {
|
|
1792
|
-
ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
|
|
1793
|
-
}
|
|
1794
|
-
}
|
|
1795
|
-
const effectiveTransport = args.transport ?? ctx.currentTransport?.get() ?? undefined;
|
|
1796
|
-
const resolved = await resolveWriteTransport(ctx, 'MSAG', className, effectiveTransport);
|
|
1797
|
-
// 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
|
|
1798
|
-
if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
|
|
1799
|
-
return {
|
|
1800
|
-
content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(className, resolved.owning) }),
|
|
1801
|
-
is_error: true,
|
|
1802
|
-
};
|
|
1803
|
-
}
|
|
1804
|
-
// ── Gate 2: Snapshot (Rule 7) — HARD gate, of the whole class ────────────
|
|
1805
|
-
let snapOutcome;
|
|
1806
|
-
try {
|
|
1807
|
-
snapOutcome = await autoSnapshotMessageClass(className, msagClient);
|
|
1808
|
-
}
|
|
1809
|
-
catch (err) {
|
|
1810
|
-
return {
|
|
1811
|
-
content: JSON.stringify({ error: ERR.SNAPSHOT_FAILED, detail: String(err) }),
|
|
1812
|
-
is_error: true,
|
|
1813
|
-
};
|
|
1814
|
-
}
|
|
1815
|
-
if (snapOutcome.reason === 'object_not_found') {
|
|
1816
|
-
return {
|
|
1817
|
-
content: JSON.stringify({
|
|
1818
|
-
error: 'message_class_not_found',
|
|
1819
|
-
detail: `Message class ${className} does not exist. Create it first with sap_create_object type MSAG, then add the messages.`,
|
|
1820
|
-
}),
|
|
1821
|
-
is_error: true,
|
|
1822
|
-
};
|
|
1823
|
-
}
|
|
1824
|
-
if (!snapOutcome.taken) {
|
|
1825
|
-
return {
|
|
1826
|
-
content: JSON.stringify({
|
|
1827
|
-
error: ERR.SNAPSHOT_FAILED,
|
|
1828
|
-
reason: snapOutcome.reason ?? 'source_read_failed',
|
|
1829
|
-
detail: snapOutcome.detail,
|
|
1830
|
-
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.',
|
|
1831
|
-
}),
|
|
1832
|
-
is_error: true,
|
|
1833
|
-
};
|
|
1834
|
-
}
|
|
1835
|
-
emitSnapshotNotice(ctx, snapOutcome, 'MSAG', className);
|
|
1836
|
-
// ── WAL: record pending entry ────────────────────────────────────────────
|
|
1837
|
-
await appendPending(ctx.session, {
|
|
1838
|
-
tool_use_id: toolUseId,
|
|
1839
|
-
tool: 'sap_message_maintain',
|
|
1840
|
-
args: { messageClass: className, messages: rows.map((r) => r.msgno) },
|
|
1841
|
-
started_at: new Date().toISOString(),
|
|
1842
|
-
});
|
|
1843
|
-
// ── Write (sap-client locks, PUTs, unlocks and reads back) ───────────────
|
|
1844
|
-
let result;
|
|
1845
|
-
try {
|
|
1846
|
-
result = await msagClient.maintainMessages({
|
|
1847
|
-
name: className,
|
|
1848
|
-
messages: rows,
|
|
1849
|
-
...(resolved.transport ? { transport: resolved.transport } : {}),
|
|
1850
|
-
});
|
|
1851
|
-
}
|
|
1852
|
-
catch (err) {
|
|
1853
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
1854
|
-
await finalizeToolCall(ctx.session, toolUseId, detail, true, Date.now() - startedAt);
|
|
1855
|
-
return {
|
|
1856
|
-
content: JSON.stringify({ error: 'message_maintain_failed', detail }),
|
|
1857
|
-
is_error: true,
|
|
1858
|
-
};
|
|
1859
|
-
}
|
|
1860
|
-
const lockNote = lockWarning(className, result);
|
|
1861
|
-
if (lockNote)
|
|
1862
|
-
ctx.chunkEmitter?.emit('warn', lockNote);
|
|
1863
|
-
if (!result.verified) {
|
|
1864
|
-
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
|
|
1865
|
-
return {
|
|
1866
|
-
content: JSON.stringify({
|
|
1867
|
-
error: 'message_maintain_failed',
|
|
1868
|
-
...(lockNote ? { lock_warning: lockNote } : {}),
|
|
1869
|
-
detail: result.error ?? 'The messages could not be confirmed in SAP.',
|
|
1870
|
-
missing: result.missing,
|
|
1871
|
-
...(result.httpStatus !== undefined ? { http_status: result.httpStatus } : {}),
|
|
1872
|
-
...(result.lockedInTransport ? {
|
|
1873
|
-
locked_in_transport: result.lockedInTransport,
|
|
1874
|
-
recovery: `Retry this exact call with transport=${result.lockedInTransport} — that request already owns the message class.`,
|
|
1875
|
-
} : {}),
|
|
1876
|
-
}),
|
|
1877
|
-
is_error: true,
|
|
1878
|
-
};
|
|
1879
|
-
}
|
|
1880
|
-
if (result.noChange) {
|
|
1881
|
-
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ unchanged: true }), false, Date.now() - startedAt);
|
|
1882
|
-
return {
|
|
1883
|
-
content: JSON.stringify({
|
|
1884
|
-
written: false,
|
|
1885
|
-
unchanged: true,
|
|
1886
|
-
message_class: className,
|
|
1887
|
-
changes: result.changes,
|
|
1888
|
-
message: `Message class ${className} already has these texts. Nothing was changed.`,
|
|
1889
|
-
}),
|
|
1890
|
-
};
|
|
1891
|
-
}
|
|
1892
|
-
// Audit only after SAP's read-back confirmed every message.
|
|
1893
|
-
await auditLog('maintainMessages', 'MSAG', className, 'success', Date.now() - startedAt, result.changes.map((c) => `${c.msgno}:${c.action}`).join(' '));
|
|
1894
|
-
noteWriteTransport(resolved.transport); // F-L2-1 statusline wire
|
|
1895
|
-
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ written: true, verified: true, changes: result.changes }), false, Date.now() - startedAt);
|
|
1896
|
-
const written = result.changes.filter((c) => c.action !== 'unchanged').map((c) => c.msgno);
|
|
1897
|
-
ctx.chunkEmitter?.emit('info', `messages ✓ ${className} ${written.join(' ')} (no activation needed)`);
|
|
1898
|
-
const first = written[0] ?? rows[0].msgno;
|
|
1899
|
-
return {
|
|
1900
|
-
content: JSON.stringify({
|
|
1901
|
-
written: true,
|
|
1902
|
-
verified: true,
|
|
1903
|
-
message_class: className,
|
|
1904
|
-
changes: result.changes,
|
|
1905
|
-
...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
|
|
1906
|
-
...(lockNote ? { lock_warning: lockNote } : {}),
|
|
1907
|
-
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.`,
|
|
1908
|
-
}),
|
|
1909
|
-
};
|
|
1910
|
-
},
|
|
1219
|
+
handler: writeToolHandler(MESSAGE_MAINTAIN_SPEC),
|
|
1911
1220
|
});
|
|
1912
1221
|
// ── sap_number_range_intervals (STUB) ─────────────────────────────────────────
|
|
1913
1222
|
//
|
|
@@ -1915,6 +1224,7 @@ registerTool({
|
|
|
1915
1224
|
// in @cspeach/sap-client.
|
|
1916
1225
|
registerTool({
|
|
1917
1226
|
name: 'sap_number_range_intervals',
|
|
1227
|
+
requires: 'nrob',
|
|
1918
1228
|
description: 'STUB — not currently implemented; do not call. '
|
|
1919
1229
|
+ 'Writing NROB number-range intervals via ADT requires NR-object XML format '
|
|
1920
1230
|
+ 'not yet implemented in @cspeach/sap-client.',
|