@cspeach/cli 1.1.34 → 1.1.36
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/.gitattributes +1 -0
- package/dist/agent/loop.js +127 -31
- package/dist/agent/parallel-write-guard.js +3 -1
- package/dist/commands/plan-audit-evidence.js +6 -1
- package/dist/commands/plan-gate.js +10 -1
- package/dist/commands/plan-resume.js +9 -0
- package/dist/lib/graceful-exit.js +17 -0
- package/dist/one-shot.js +3 -0
- package/dist/projects/answer-blockers.js +25 -12
- package/dist/projects/extract-plan.js +6 -0
- package/dist/projects/plan-objects.js +1 -1
- package/dist/projects/plan-skeleton.js +93 -0
- package/dist/projects/save-command.js +17 -0
- package/dist/rap/finish-checks.js +389 -0
- package/dist/rap/generator-names.js +57 -0
- package/dist/rap/skeleton-card.js +31 -0
- package/dist/rap/skeleton-rules.js +38 -0
- package/dist/rap/skeleton-session.js +21 -0
- package/dist/rap/skeleton-store.js +40 -0
- package/dist/renderer/change-card.js +28 -4
- package/dist/renderer/notices.js +27 -13
- package/dist/renderer/progress-chatter.js +3 -1
- package/dist/renderer/tool-labels.js +12 -3
- package/dist/renderer/verify-chain.js +41 -18
- package/dist/renderer/wrap-rows.js +87 -0
- package/dist/repl/rule8-detector.js +3 -2
- package/dist/repl.js +11 -2
- package/dist/sap/connection-manager.js +15 -0
- package/dist/sap/generator-fact.js +21 -0
- package/dist/sap/session-end.js +66 -0
- package/dist/session/audit-export.js +19 -1
- package/dist/tools/ask-question.js +1 -1
- package/dist/tools/rap/finish-check.js +118 -0
- package/dist/tools/rap/generate-objects.js +317 -0
- package/dist/tools/rap/skeleton-resolve.js +283 -0
- package/dist/tools/sap-read.js +22 -22
- package/dist/tools/sap-write.js +249 -18
- package/dist/tools/snapshot.js +26 -0
- package/dist/tools/tool-loading.js +7 -1
- package/dist/tools/write-mode.js +2 -0
- package/dist/ui/app.js +19 -9
- package/dist/ui/approval-modal.js +37 -5
- package/dist/ui/live-area.js +14 -1
- package/dist/ui/widgets/ask-card.js +183 -107
- package/dist/ui/widgets/coaching-picker.js +22 -1
- package/dist/ui/widgets/confirm-card.js +102 -76
- package/package.json +2 -2
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/rap/finish-check.ts
|
|
2
|
+
//
|
|
3
|
+
// RAP generator plan, Task 10 — `rap_finish_check` (spec 2026-10-14 §6).
|
|
4
|
+
// Reads the app's live sources (root + projection DDLS, root BDEF, behavior
|
|
5
|
+
// pool CCIMP, projection DDLX, table, access controls) and reports the Finish
|
|
6
|
+
// items F1–F5 item by item, so the model cannot claim them done.
|
|
7
|
+
//
|
|
8
|
+
// A read that fails is never a pass:
|
|
9
|
+
// - a 404 on the DDLX is the F4 problem "No metadata extension for <projection>";
|
|
10
|
+
// - any other failed read (a 404 on the DDLS, BDEF, class include or table,
|
|
11
|
+
// or any non-404 error) is a tool error naming the object.
|
|
12
|
+
import { registerTool } from '../index.js';
|
|
13
|
+
import { checkFinish, FINISH_TITLES } from '../../rap/finish-checks.js';
|
|
14
|
+
const fail = (m) => ({ content: `error: ${m}`, is_error: true });
|
|
15
|
+
const NAME_RE = /^[A-Za-z0-9_/]{1,40}$/;
|
|
16
|
+
class ReadFailed extends Error {
|
|
17
|
+
}
|
|
18
|
+
function httpStatus(e) {
|
|
19
|
+
const s = e?.httpStatus;
|
|
20
|
+
return typeof s === 'number' ? s : undefined;
|
|
21
|
+
}
|
|
22
|
+
/** Reads one object; a 404 returns undefined only when `allow404`, every other failure throws ReadFailed. */
|
|
23
|
+
async function read(what, fn, allow404 = false) {
|
|
24
|
+
try {
|
|
25
|
+
return await fn();
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
const status = httpStatus(e);
|
|
29
|
+
if (status === 404 && allow404)
|
|
30
|
+
return undefined;
|
|
31
|
+
if (status === 404)
|
|
32
|
+
throw new ReadFailed(`${what} not found. Build the app first, then run the finish check.`);
|
|
33
|
+
const why = status !== undefined ? `HTTP ${status}` : (e instanceof Error ? e.message : String(e));
|
|
34
|
+
throw new ReadFailed(`could not read ${what} (${why}). Nothing was checked.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function formatFinish(items) {
|
|
38
|
+
const passed = items.filter((i) => i.ok).length;
|
|
39
|
+
return [
|
|
40
|
+
`Finish check: ${passed} of ${items.length} passed`,
|
|
41
|
+
...items.map((i) => `${i.id} ${FINISH_TITLES[i.id]} — ${i.ok ? 'passed' : `needs work: ${i.problems.join('; ')}`}`),
|
|
42
|
+
].join('\n');
|
|
43
|
+
}
|
|
44
|
+
export async function rapFinishCheckHandler(args, ctx) {
|
|
45
|
+
const names = {};
|
|
46
|
+
for (const k of ['root', 'projection', 'behaviorPool', 'entity']) {
|
|
47
|
+
const v = args[k];
|
|
48
|
+
if (typeof v !== 'string' || !NAME_RE.test(v.trim()))
|
|
49
|
+
return fail(`${k} is required (an object name)`);
|
|
50
|
+
names[k] = k === 'entity' ? v.trim() : v.trim().toUpperCase();
|
|
51
|
+
}
|
|
52
|
+
const { root, projection, behaviorPool, entity } = names;
|
|
53
|
+
let table;
|
|
54
|
+
if (args.table !== undefined && args.table !== null && args.table !== '') {
|
|
55
|
+
if (typeof args.table !== 'string' || !NAME_RE.test(args.table.trim()))
|
|
56
|
+
return fail('table must be a table name');
|
|
57
|
+
table = args.table.trim().toUpperCase();
|
|
58
|
+
}
|
|
59
|
+
let authObject;
|
|
60
|
+
if (args.authObject !== undefined && args.authObject !== null && args.authObject !== '') {
|
|
61
|
+
if (typeof args.authObject !== 'string' || !/^[A-Za-z0-9_]{1,10}$/.test(args.authObject.trim())) {
|
|
62
|
+
return fail('authObject must be an authorization object name');
|
|
63
|
+
}
|
|
64
|
+
authObject = args.authObject.trim().toUpperCase();
|
|
65
|
+
}
|
|
66
|
+
let codedFields = [];
|
|
67
|
+
if (args.codedFields !== undefined && args.codedFields !== null) {
|
|
68
|
+
if (!Array.isArray(args.codedFields) || !args.codedFields.every((f) => typeof f === 'string' && /^\w+$/.test(f.trim()))) {
|
|
69
|
+
return fail('codedFields must be a list of element names');
|
|
70
|
+
}
|
|
71
|
+
codedFields = args.codedFields.map((f) => f.trim());
|
|
72
|
+
}
|
|
73
|
+
const adt = ctx.adt;
|
|
74
|
+
let src;
|
|
75
|
+
try {
|
|
76
|
+
const rootDdls = (await read(`CDS view ${root}`, () => adt.getSource('DDLS', root)));
|
|
77
|
+
const projectionDdls = (await read(`CDS view ${projection}`, () => adt.getSource('DDLS', projection)));
|
|
78
|
+
const rootBdef = (await read(`behavior definition ${root}`, () => adt.getSource('BDEF', root)));
|
|
79
|
+
const ccimp = String((await read(`local types of ${behaviorPool}`, () => adt.classIncludes(behaviorPool, 'locals_imp'))));
|
|
80
|
+
const ddlx = await read(`metadata extension ${projection}`, () => adt.getSource('DDLX', projection), true);
|
|
81
|
+
const tableSrc = table ? await read(`table ${table}`, () => adt.getSource('TABL', table)) : undefined;
|
|
82
|
+
const dclsNames = [];
|
|
83
|
+
for (const n of [root, projection]) {
|
|
84
|
+
const hits = (await read(`access controls for ${n}`, () => adt.searchObject(n, 'DCLS', 10)));
|
|
85
|
+
if (hits.some((h) => h.name.toUpperCase() === n && /^DCLS(\/|$)/i.test(h.type)))
|
|
86
|
+
dclsNames.push(n);
|
|
87
|
+
}
|
|
88
|
+
src = { rootDdls, projectionDdls, rootBdef, ccimp, dclsNames, ...(ddlx !== undefined ? { ddlx } : {}), ...(tableSrc !== undefined ? { table: tableSrc } : {}) };
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
if (e instanceof ReadFailed)
|
|
92
|
+
return fail(e.message);
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
return { content: formatFinish(checkFinish(src, { root, projection, entity, authObject, codedFields })) };
|
|
96
|
+
}
|
|
97
|
+
registerTool({
|
|
98
|
+
name: 'rap_finish_check',
|
|
99
|
+
description: 'Check the Finish items F1–F5 of a RAP app against its live sources (authorization, access control, '
|
|
100
|
+
+ 'read-only admin fields, labels, coded fields). Read-only. Run it after the Finish step and before the UI. '
|
|
101
|
+
+ 'A failed item lists what to fix; fix only those and run it again.',
|
|
102
|
+
isMutating: false,
|
|
103
|
+
requiresSap: true,
|
|
104
|
+
input_schema: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
root: { type: 'string', description: 'Root interface view (ZR_…); also the root behavior definition' },
|
|
108
|
+
projection: { type: 'string', description: 'Projection view (ZC_…); its metadata extension has the same name' },
|
|
109
|
+
behaviorPool: { type: 'string', description: 'Behavior pool class (ZBP_…)' },
|
|
110
|
+
entity: { type: 'string', description: 'The BDEF entity alias, e.g. SbsBook2 (the app title must not be this)' },
|
|
111
|
+
table: { type: 'string', description: 'Persistence table, to check a UUID key uses managed numbering' },
|
|
112
|
+
authObject: { type: 'string', description: 'Authorization object from the spec; the auth method must check it' },
|
|
113
|
+
codedFields: { type: 'array', items: { type: 'string' }, description: 'Elements with a fixed code list (status, type, currency …)' },
|
|
114
|
+
},
|
|
115
|
+
required: ['root', 'projection', 'behaviorPool', 'entity'],
|
|
116
|
+
},
|
|
117
|
+
handler: (args, ctx) => rapFinishCheckHandler(args, ctx),
|
|
118
|
+
});
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/rap/generate-objects.ts
|
|
2
|
+
//
|
|
3
|
+
// RAP generator plan, Task 9 — `sap_generate_objects` (spec 2026-10-14 §5).
|
|
4
|
+
// Runs SAP's RAP generator (uiservice) for a skeleton phase whose decision
|
|
5
|
+
// says sap-generator, under the usual write gates:
|
|
6
|
+
// decision file → approvals (one per planned object) → transport fit →
|
|
7
|
+
// table ready → generator offered → validate again → preview again →
|
|
8
|
+
// pending entry → generate → read back (active, on the plan's transport) →
|
|
9
|
+
// audit + finalize.
|
|
10
|
+
// A failure stops and returns a plain-words error. A failed generate (an SAP
|
|
11
|
+
// error answer OR a throw) or a failed read back shows a card listing the
|
|
12
|
+
// objects that now exist; nothing switches path silently.
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import { auditLog } from '@cspeach/sap-client';
|
|
15
|
+
import { registerTool } from '../index.js';
|
|
16
|
+
import fsp from 'node:fs/promises';
|
|
17
|
+
import nodePath from 'node:path';
|
|
18
|
+
import { planPinRoots } from '../../projects/plan-lane.js';
|
|
19
|
+
import { getActivePlanPath } from '../../commands/plan-gate.js';
|
|
20
|
+
import { cardLine } from '../../lane/card.js';
|
|
21
|
+
import { readSkeletonDecision } from '../../rap/skeleton-store.js';
|
|
22
|
+
import { verifyAndSpendApprovalId } from '../../approvals/jwt.js';
|
|
23
|
+
import { grantClaimMismatch } from '../../approvals/grant-claims.js';
|
|
24
|
+
import { checkTransportFit } from '../transport-fit-check.js';
|
|
25
|
+
import { appendPending, finalizeToolCall } from '../../session/pending.js';
|
|
26
|
+
import { noteWriteTransport } from '../../repl/current-transport.js';
|
|
27
|
+
import { askConfirm } from '../../ui/confirm-request.js';
|
|
28
|
+
import { cardSlotEmitter } from '../../ui/card-slot.js';
|
|
29
|
+
import { isHeadless } from '../../renderer/tty.js';
|
|
30
|
+
const TS_FIELDS = [/:\s*abp_locinst_lastchange_tstmpl\b/i, /:\s*abp_lastchange_tstmpl\b/i];
|
|
31
|
+
const REPLAN = 'Run /abap-plan again.';
|
|
32
|
+
/** Fix round 1 (M3): said on every pre-write stop after the approvals were checked. */
|
|
33
|
+
const SPENT = 'Your approvals for this run were used; you will be asked again.';
|
|
34
|
+
const spent = (next) => `${next} ${SPENT}`;
|
|
35
|
+
const NEXT_BY_CHOICE = {
|
|
36
|
+
'delete-retry': 'Delete them with sap_delete_object in this order (each asks for approval), then call sap_generate_objects again.',
|
|
37
|
+
'delete-switch': 'Delete them in this order, then re-run /abap-plan and choose the CSPeach writer on the skeleton card.',
|
|
38
|
+
stop: 'Stopped. Nothing else was changed.',
|
|
39
|
+
};
|
|
40
|
+
const code = (t) => String(t ?? '').toUpperCase().split('/')[0].trim();
|
|
41
|
+
const up = (s) => String(s ?? '').trim().toUpperCase();
|
|
42
|
+
const label = (o) => `${code(o.type)} ${o.name}`;
|
|
43
|
+
const sameObj = (a, b) => code(a.type) === code(b.type) && up(a.name) === up(b.name);
|
|
44
|
+
/** Spec §5: binding → service definition → DDLX → projection BDEF/view → behavior pool → root BDEF/view → draft table. */
|
|
45
|
+
const REVERSE_ORDER = (n) => [
|
|
46
|
+
{ type: 'SRVB', name: n.serviceBinding }, { type: 'SRVD', name: n.serviceDefinition },
|
|
47
|
+
{ type: 'DDLX', name: n.projection }, { type: 'BDEF', name: n.projection }, { type: 'DDLS', name: n.projection },
|
|
48
|
+
{ type: 'CLAS', name: n.behaviorPool }, { type: 'BDEF', name: n.root }, { type: 'DDLS', name: n.root },
|
|
49
|
+
{ type: 'TABL', name: n.draftTable },
|
|
50
|
+
];
|
|
51
|
+
function failureCardRequest(created, why) {
|
|
52
|
+
return {
|
|
53
|
+
id: 'rap-generate-failed',
|
|
54
|
+
tag: 'SAFETY CHECK',
|
|
55
|
+
title: "SAP's generator did not finish",
|
|
56
|
+
facts: [cardLine(why), ...(created.length
|
|
57
|
+
? [cardLine('These objects now exist:'), ...created.map((o) => cardLine(label(o)))]
|
|
58
|
+
: [cardLine('No objects were created.')]),
|
|
59
|
+
cardLine('They are new, so there is nothing to restore if they are deleted.')],
|
|
60
|
+
choices: [
|
|
61
|
+
{ value: 'delete-retry', label: cardLine('Delete them and try again') },
|
|
62
|
+
{ value: 'delete-switch', label: cardLine('Delete them and build with the CSPeach writer instead') },
|
|
63
|
+
{ value: 'stop', label: cardLine('Leave them and stop') },
|
|
64
|
+
],
|
|
65
|
+
initial: 2,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function defaultHeadless() {
|
|
69
|
+
return isHeadless() || cardSlotEmitter.listenerCount('confirm') === 0;
|
|
70
|
+
}
|
|
71
|
+
const errorResult = (error, message, next, extras = {}) => ({
|
|
72
|
+
content: JSON.stringify({ error, message, next, ...extras }),
|
|
73
|
+
is_error: true,
|
|
74
|
+
});
|
|
75
|
+
/**
|
|
76
|
+
* Final review M1 — the root the decision file is read from, resolved like the
|
|
77
|
+
* plan's lane file (projects/plan-lane.ts readPlanLane): the running plan's own
|
|
78
|
+
* root first, then the lane root for cwd. The first root that HAS the file
|
|
79
|
+
* decides; with none, the last (cwd's lane root) gives the usual "missing".
|
|
80
|
+
*/
|
|
81
|
+
async function decisionRoot(cwd, rel) {
|
|
82
|
+
const roots = await planPinRoots(cwd, getActivePlanPath() ?? undefined);
|
|
83
|
+
const norm = rel.replace(/\\/g, '/');
|
|
84
|
+
for (const root of roots.slice(0, -1)) {
|
|
85
|
+
if (await fsp.stat(nodePath.join(root, norm)).then(() => true, () => false))
|
|
86
|
+
return root;
|
|
87
|
+
}
|
|
88
|
+
return roots[roots.length - 1];
|
|
89
|
+
}
|
|
90
|
+
const errText = (e) => (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
91
|
+
export async function sapGenerateObjectsHandler(args, ctx, deps = {}) {
|
|
92
|
+
// ── 1. Decision file ──────────────────────────────────────────────────────
|
|
93
|
+
let rec;
|
|
94
|
+
{
|
|
95
|
+
let read;
|
|
96
|
+
try {
|
|
97
|
+
read = await readSkeletonDecision(await decisionRoot(ctx.cwd, String(args?.decisionPath ?? '')), String(args?.decisionPath ?? ''), String(args?.decisionSha256 ?? ''));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
read = { ok: false, message: 'The skeleton decision file could not be read. Run /abap-plan again.' };
|
|
101
|
+
}
|
|
102
|
+
if (!read.ok)
|
|
103
|
+
return errorResult('decision_invalid', read.message, REPLAN);
|
|
104
|
+
rec = read.rec;
|
|
105
|
+
}
|
|
106
|
+
if (rec.decision?.by !== 'sap-generator' || !rec.content || !Array.isArray(rec.objects) || rec.objects.length === 0) {
|
|
107
|
+
return errorResult('not_generator_path', "This skeleton is built by the CSPeach writer, not SAP's generator.", 'Build the skeleton with the normal create tools, as the plan says.');
|
|
108
|
+
}
|
|
109
|
+
const effective = up(args?.transport) || up(ctx.currentTransport?.get()) || '';
|
|
110
|
+
// ── 2. Approvals: one per planned object, same order ─────────────────────
|
|
111
|
+
const ids = Array.isArray(args?.approval_ids) ? args.approval_ids.map((x) => String(x ?? '')) : [];
|
|
112
|
+
if (ids.length !== rec.objects.length) {
|
|
113
|
+
return errorResult('approval_invalid', `${rec.objects.length} approvals are needed, one for each planned object. ${ids.length} were given.`, 'Request one create approval per planned object, in the plan order, then call this again.');
|
|
114
|
+
}
|
|
115
|
+
// One pass in order; stops at the first failure. A partly spent set is fine:
|
|
116
|
+
// no SAP write has happened and the run stops.
|
|
117
|
+
const payloads = [];
|
|
118
|
+
for (let i = 0; i < ids.length; i++) {
|
|
119
|
+
const o = rec.objects[i];
|
|
120
|
+
const v = await verifyAndSpendApprovalId(ids[i], o.name, 'create');
|
|
121
|
+
if (!v.ok) {
|
|
122
|
+
// Final review M6 — ids before this one were spent: ask for the whole set again, never just one.
|
|
123
|
+
return errorResult('approval_invalid', `The approval for ${label(o)} is not valid.`, spent('Request one create approval per planned object, in the plan order, then call this again.'), { reason: v.reason, object: label(o) });
|
|
124
|
+
}
|
|
125
|
+
payloads.push(v.payload);
|
|
126
|
+
}
|
|
127
|
+
for (let i = 0; i < payloads.length; i++) {
|
|
128
|
+
const o = rec.objects[i];
|
|
129
|
+
const claim = grantClaimMismatch(payloads[i], { type: o.type, package: rec.package, transport: effective || null });
|
|
130
|
+
if (claim) {
|
|
131
|
+
return errorResult('approval_invalid', `The approval for ${label(o)} does not match this write.`, spent("Request one create approval per planned object on the plan's transport, in the plan order, then call this again."), { reason: claim, object: label(o) });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// ── 3. Transport ──────────────────────────────────────────────────────────
|
|
135
|
+
if (!effective) {
|
|
136
|
+
return errorResult('transport_missing', 'No transport is set for this write.', spent('Pass the plan\'s transport, or set one with /transport, then call this again.'));
|
|
137
|
+
}
|
|
138
|
+
const fit = await checkTransportFit(ctx.adt, { type: 'DDLS', name: rec.names.root, pkg: rec.package, transport: effective });
|
|
139
|
+
if (!fit.ok)
|
|
140
|
+
return { content: JSON.stringify({ ...fit.refusal, next: SPENT }), is_error: true };
|
|
141
|
+
// ── 4–7. SAP reads before the write. A read that throws stops cleanly. ────
|
|
142
|
+
const tableUri = `/sap/bc/adt/ddic/tables/${rec.table.toLowerCase()}`;
|
|
143
|
+
let entry;
|
|
144
|
+
try {
|
|
145
|
+
// 4. Table is ready.
|
|
146
|
+
let src = null;
|
|
147
|
+
try {
|
|
148
|
+
src = await ctx.adt.getSource('TABL', rec.table);
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
if (e?.httpStatus !== 404)
|
|
152
|
+
throw e;
|
|
153
|
+
}
|
|
154
|
+
if (src === null || !TS_FIELDS.every((re) => re.test(src))) {
|
|
155
|
+
return errorResult('table_not_ready', src === null
|
|
156
|
+
? `Table ${rec.table} does not exist, so SAP's generator cannot run. Re-run /abap-plan: CSPeach will choose its own writer.`
|
|
157
|
+
: `Table ${rec.table} lacks the fields SAP's generator needs. Re-run /abap-plan: CSPeach will choose its own writer.`, spent('Re-run /abap-plan.'));
|
|
158
|
+
}
|
|
159
|
+
// 5. Generator entry.
|
|
160
|
+
const found = (await ctx.adt.listGenerators(tableUri)).find((g) => g.id === 'uiservice');
|
|
161
|
+
if (!found) {
|
|
162
|
+
return errorResult('generator_not_offered', `SAP's generator isn't offered for table ${rec.table}.`, spent('Re-run /abap-plan: CSPeach will choose its own writer.'));
|
|
163
|
+
}
|
|
164
|
+
entry = found;
|
|
165
|
+
// 6. Validate again (Review Focus 2) — before preview: preview does not check names.
|
|
166
|
+
const v = await ctx.adt.validateGenerator(entry, rec.content);
|
|
167
|
+
if (!v.ok) {
|
|
168
|
+
return errorResult('name_clash', `SAP's generator refused the planned names: ${cardLine(v.text || v.severity || 'no reason given')}`, spent('Choose new names: re-run /abap-plan. The object list changes, so you will approve it again.'));
|
|
169
|
+
}
|
|
170
|
+
// 7. Preview again (Review Focus 1).
|
|
171
|
+
const preview = (await ctx.adt.previewGenerator(entry, rec.content)).map((p) => ({ type: p.type, name: p.name }));
|
|
172
|
+
const missing = rec.objects.filter((o) => !preview.some((p) => sameObj(p, o)));
|
|
173
|
+
const extra = preview.filter((p) => !rec.objects.some((o) => sameObj(p, o)));
|
|
174
|
+
if (missing.length || extra.length) {
|
|
175
|
+
const parts = [
|
|
176
|
+
...(missing.length ? [`Planned but not in SAP's preview: ${missing.map(label).join(', ')}.`] : []),
|
|
177
|
+
...(extra.length ? [`In SAP's preview but not planned: ${extra.map(label).join(', ')}.`] : []),
|
|
178
|
+
];
|
|
179
|
+
return errorResult('preview_differs', `SAP's generator would not build what the plan lists. ${parts.join(' ')}`, spent('Re-run /abap-plan. The object list changes, so you will approve it again.'), { missing: missing.map((o) => ({ type: code(o.type), name: o.name })), extra: extra.map((o) => ({ type: code(o.type), name: o.name })) });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
return errorResult('sap_read_failed', `Could not read from SAP before generating: ${cardLine(errText(e))}. Nothing was changed.`, spent('Check the connection to SAP, then call this again.'));
|
|
184
|
+
}
|
|
185
|
+
// ── 8. Write-ahead entry ──────────────────────────────────────────────────
|
|
186
|
+
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
187
|
+
const startedAt = Date.now();
|
|
188
|
+
await appendPending(ctx.session, {
|
|
189
|
+
tool_use_id: toolUseId,
|
|
190
|
+
tool: 'sap_generate_objects',
|
|
191
|
+
args: { decisionPath: String(args.decisionPath), table: rec.table, package: rec.package, transport: effective },
|
|
192
|
+
started_at: new Date().toISOString(),
|
|
193
|
+
});
|
|
194
|
+
const planned = rec.objects.map((o) => ({ type: code(o.type), name: o.name }));
|
|
195
|
+
const inDeleteOrder = (created) => REVERSE_ORDER(rec.names).filter((r) => created.some((c) => sameObj(c, r)));
|
|
196
|
+
/** Card (or headless stop), finalize, error result. */
|
|
197
|
+
const failWithCard = async (error, message, created, extras = {}) => {
|
|
198
|
+
let choice = 'stop';
|
|
199
|
+
if (!(deps.headless ?? defaultHeadless)()) {
|
|
200
|
+
try {
|
|
201
|
+
const res = await (deps.confirm ?? ((r) => askConfirm(r)))(failureCardRequest(created, message));
|
|
202
|
+
if (res.value === 'delete-retry' || res.value === 'delete-switch')
|
|
203
|
+
choice = res.value;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
choice = 'stop';
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const out = errorResult(error, message, NEXT_BY_CHOICE[choice], { created, delete_in_order: inDeleteOrder(created), choice, ...extras });
|
|
210
|
+
// Fix round 1 (M2): every stop after the pending entry leaves an error row.
|
|
211
|
+
await auditLog('generateObjects', 'GENR', rec.names.root, 'error', Date.now() - startedAt, `${error} created=${created.length} transport=${effective}`);
|
|
212
|
+
await finalizeToolCall(ctx.session, toolUseId, out.content, true, Date.now() - startedAt);
|
|
213
|
+
return out;
|
|
214
|
+
};
|
|
215
|
+
/** Which planned objects exist: exact name AND the planned type code (M4: an SRVD hit is not the SRVB). */
|
|
216
|
+
const findExisting = async () => {
|
|
217
|
+
const found = [];
|
|
218
|
+
const unchecked = [];
|
|
219
|
+
for (const o of planned) {
|
|
220
|
+
try {
|
|
221
|
+
const hits = await ctx.adt.searchObject(o.name, o.type, 5);
|
|
222
|
+
if (hits.some((h) => up(h.name) === up(o.name) && code(h.type) === o.type))
|
|
223
|
+
found.push(o);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
unchecked.push(o);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { found, unchecked };
|
|
230
|
+
};
|
|
231
|
+
// ── 9. Generate. An error answer and a throw are handled the same way. ───
|
|
232
|
+
let failure = null;
|
|
233
|
+
try {
|
|
234
|
+
const run = await ctx.adt.runGenerator(entry, rec.content, effective);
|
|
235
|
+
if (!(run.status >= 200 && run.status < 300))
|
|
236
|
+
failure = run.error || `SAP answered HTTP ${run.status}.`;
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
failure = `The request to SAP failed: ${errText(e)}`;
|
|
240
|
+
}
|
|
241
|
+
if (failure !== null) {
|
|
242
|
+
// Which planned objects now exist (exact name, same type)?
|
|
243
|
+
const { found: created, unchecked } = await findExisting();
|
|
244
|
+
const note = unchecked.length ? ` Could not check: ${unchecked.map(label).join(', ')}.` : '';
|
|
245
|
+
return failWithCard('generate_failed', `SAP's generator failed. ${cardLine(failure)}${note}`, created, unchecked.length ? { unchecked } : {});
|
|
246
|
+
}
|
|
247
|
+
// ── 10. Read back: every object exists, is active and is on the plan's transport (spec §5 step 6).
|
|
248
|
+
{
|
|
249
|
+
const { found, unchecked } = await findExisting();
|
|
250
|
+
if (unchecked.length) {
|
|
251
|
+
return failWithCard('readback_failed', `The objects were generated, but CSPeach could not check that these exist: ${unchecked.map(label).join(', ')}.`, planned);
|
|
252
|
+
}
|
|
253
|
+
const missing = planned.filter((o) => !found.some((f) => sameObj(f, o)));
|
|
254
|
+
if (missing.length) {
|
|
255
|
+
return failWithCard('missing_after_generate', `SAP's generator reported success, but these objects do not exist: ${missing.map(label).join(', ')}.`, found, { missing });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const inactive = await ctx.adt.inactiveObjects();
|
|
260
|
+
const stillInactive = planned.filter((o) => inactive.some((i) => sameObj(i, o)));
|
|
261
|
+
if (stillInactive.length) {
|
|
262
|
+
return failWithCard('inactive_after_generate', `SAP's generator left these objects inactive: ${stillInactive.map(label).join(', ')}.`, planned, { inactive: stillInactive });
|
|
263
|
+
}
|
|
264
|
+
const wrong = [];
|
|
265
|
+
const capped = [];
|
|
266
|
+
for (const o of planned) {
|
|
267
|
+
const t = await ctx.adt.transportForObject(o.type, o.name);
|
|
268
|
+
const candidates = (Array.isArray(t.candidates) ? t.candidates : []).map(up);
|
|
269
|
+
if (t.capped)
|
|
270
|
+
capped.push(o);
|
|
271
|
+
else if (t.transport && up(t.transport) !== effective)
|
|
272
|
+
wrong.push({ ...o, transport: up(t.transport) });
|
|
273
|
+
// Split ownership (M1): no single owner, and the plan's request is not among the owners.
|
|
274
|
+
else if (!t.transport && candidates.length && !candidates.includes(effective))
|
|
275
|
+
wrong.push({ ...o, transport: candidates.join(', ') });
|
|
276
|
+
// Null with no candidates: in no open request; existence was proven above.
|
|
277
|
+
}
|
|
278
|
+
if (capped.length) {
|
|
279
|
+
return failWithCard('readback_failed', `The objects were generated, but CSPeach could not check the transport of: ${capped.map(label).join(', ')}.`, planned);
|
|
280
|
+
}
|
|
281
|
+
if (wrong.length) {
|
|
282
|
+
return failWithCard('wrong_transport', `These objects landed on another transport, not ${effective}: ${wrong.map((w) => `${label(w)} on ${w.transport}`).join(', ')}.`, planned, { wrong });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
return failWithCard('readback_failed', `The objects were generated, but CSPeach could not check them: ${cardLine(errText(e))}`, planned);
|
|
287
|
+
}
|
|
288
|
+
// ── 11. Finish up ─────────────────────────────────────────────────────────
|
|
289
|
+
await auditLog('generateObjects', 'GENR', rec.names.root, 'success', Date.now() - startedAt, `generator=uiservice objects=${planned.length} transport=${effective}`);
|
|
290
|
+
noteWriteTransport(effective);
|
|
291
|
+
const result = JSON.stringify({ generated: true, objects: planned, transport: effective, next: 'finish' });
|
|
292
|
+
await finalizeToolCall(ctx.session, toolUseId, result, false, Date.now() - startedAt);
|
|
293
|
+
return { content: result };
|
|
294
|
+
}
|
|
295
|
+
registerTool({
|
|
296
|
+
name: 'sap_generate_objects',
|
|
297
|
+
description: "Run SAP's RAP generator for a skeleton phase whose Plan block says sap-generator. Pass the decision path and "
|
|
298
|
+
+ 'sha256 from the plan block, and one approval_id per planned object in the same order (request_approval with '
|
|
299
|
+
+ 'op create for each). CSPeach validates, previews, generates, then checks every object is active and on the '
|
|
300
|
+
+ 'transport. Never call it for a CSPeach-writer skeleton.',
|
|
301
|
+
isMutating: true,
|
|
302
|
+
requiresSap: true,
|
|
303
|
+
input_schema: {
|
|
304
|
+
type: 'object',
|
|
305
|
+
properties: {
|
|
306
|
+
decisionPath: { type: 'string', description: 'Skeleton decision path from the Plan block' },
|
|
307
|
+
decisionSha256: { type: 'string', description: 'Skeleton decision sha256 from the Plan block' },
|
|
308
|
+
approval_ids: {
|
|
309
|
+
type: 'array', items: { type: 'string' },
|
|
310
|
+
description: 'One create approval id per planned object, in the plan order',
|
|
311
|
+
},
|
|
312
|
+
transport: { type: 'string', description: "The plan's transport (optional: defaults to the session transport)" },
|
|
313
|
+
},
|
|
314
|
+
required: ['decisionPath', 'decisionSha256', 'approval_ids'],
|
|
315
|
+
},
|
|
316
|
+
handler: (args, ctx) => sapGenerateObjectsHandler(args, ctx),
|
|
317
|
+
});
|