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