@stalfh233/omc-cli 0.4.41 → 0.4.42
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/coverage/m1-coverage-manifest-v1.json +5 -3
- package/dist/call.js +11 -5
- package/dist/cli-contract.js +27 -2
- package/dist/cli.js +11 -0
- package/dist/form-support.js +21 -1
- package/dist/journal.js +73 -0
- package/dist/lanes/app.js +23 -3
- package/dist/lanes/businessrule.js +25 -2
- package/dist/lanes/datarule.js +24 -3
- package/dist/lanes/dictionary.js +34 -3
- package/dist/lanes/field-contract.js +1 -1
- package/dist/lanes/field.js +29 -3
- package/dist/lanes/form-contract.js +1 -1
- package/dist/lanes/form.js +19 -2
- package/dist/lanes/list-view.js +25 -3
- package/dist/lanes/model.js +32 -3
- package/dist/lanes/online-js-contract.js +6 -1
- package/dist/lanes/online-js-coverage.js +1 -1
- package/dist/lanes/online-js.js +79 -2
- package/dist/lanes/report.js +30 -4
- package/dist/lanes/rule-lifecycle.js +28 -3
- package/dist/lanes/workflow-contract.js +1 -1
- package/dist/lanes/workflow.js +241 -16
- package/dist/online-js-source.js +38 -1
- package/dist/rollback-spec.js +121 -0
- package/dist/rollback.js +178 -0
- package/dist/write.js +49 -2
- package/package.json +3 -3
- package/skills/omc-business-configuration.md +6 -4
- package/skills/omc-capabilities.md +3 -3
- package/skills/omc-write-hazards.md +1 -0
package/dist/lanes/workflow.js
CHANGED
|
@@ -253,7 +253,11 @@ function lintWorkflowTemplate(template, tool) {
|
|
|
253
253
|
for (const activity of activities) {
|
|
254
254
|
const record = activity;
|
|
255
255
|
const type = record != null && typeof record === "object" ? (record.type ?? record.activityType) : undefined;
|
|
256
|
-
|
|
256
|
+
// Permission/rename rows target an existing draft node and inherit its
|
|
257
|
+
// type at merge time (see validateCallerActivities) — only a bare new
|
|
258
|
+
// node must carry its own type here.
|
|
259
|
+
const targetsExistingNode = record != null && typeof record === "object" && (record.propertyPermissions !== undefined || record.subPropertyPermissions !== undefined || record.activityName !== undefined || record.name !== undefined || record.i18nMessage !== undefined || record.permittedAction !== undefined || record.buttonPermission !== undefined);
|
|
260
|
+
if ((type === undefined || type === null || String(type).trim() === "") && !targetsExistingNode)
|
|
257
261
|
throw new ToolRefusalError({
|
|
258
262
|
status: "refused",
|
|
259
263
|
blocker: "workflow-activity-type-missing",
|
|
@@ -286,8 +290,7 @@ function mapCallerActivities(callerActivities, draftActivities) {
|
|
|
286
290
|
return callerActivities.map((entry) => {
|
|
287
291
|
const record = (entry ?? {});
|
|
288
292
|
const code = activityCodeOf(record);
|
|
289
|
-
const
|
|
290
|
-
const type = rawType === "APPROVAL" ? "PARTICIPANT" : rawType;
|
|
293
|
+
const type = activityTypeOf(record);
|
|
291
294
|
const base = draftByCode.get(code) ??
|
|
292
295
|
(type === "START" ? draftByType("START") : type === "END" ? draftByType("END") : type === "PARTICIPANT" ? draftByType("PARTICIPANT") : undefined) ??
|
|
293
296
|
{};
|
|
@@ -300,13 +303,23 @@ function mapCallerActivities(callerActivities, draftActivities) {
|
|
|
300
303
|
...Object.fromEntries(Object.entries(record).filter(([key]) => !ACTIVITY_INPUT_ALIAS_KEYS.includes(key))),
|
|
301
304
|
...(record.participant !== undefined ? { participant: normalizeParticipantForWire(record.participant) } : {}),
|
|
302
305
|
};
|
|
303
|
-
|
|
306
|
+
const mapped = {
|
|
304
307
|
...base,
|
|
305
308
|
activityCode: code,
|
|
306
309
|
...(type ? { activityType: type } : {}),
|
|
307
310
|
...(record.name !== undefined ? { activityName: record.name } : {}),
|
|
308
311
|
...wireRecord,
|
|
309
312
|
};
|
|
313
|
+
// Replace path: a caller-only code with no type has no draft row to
|
|
314
|
+
// inherit from — refuse before the wire (same rule the merge path
|
|
315
|
+
// enforces for its appended nodes).
|
|
316
|
+
if (String(mapped.activityType ?? "") === "")
|
|
317
|
+
throw new ToolRefusalError({
|
|
318
|
+
status: "refused",
|
|
319
|
+
blocker: "workflow-activity-type-missing",
|
|
320
|
+
message: `workflow: 新节点 ${code} 缺 type/activityType(省略类型仅当 activityCode 命中草稿既有节点——权限/改名编辑行)`,
|
|
321
|
+
});
|
|
322
|
+
return mapped;
|
|
310
323
|
});
|
|
311
324
|
}
|
|
312
325
|
function validateCallerActivities(template, tool) {
|
|
@@ -323,9 +336,16 @@ function validateCallerActivities(template, tool) {
|
|
|
323
336
|
throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-activity-code-invalid", message: `${tool}: activities[${index}] requires a non-empty activityCode/id` });
|
|
324
337
|
if (codes.has(code))
|
|
325
338
|
throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-activity-code-duplicate", message: `${tool}: duplicate activityCode ${code}` });
|
|
326
|
-
if (!rawType)
|
|
327
|
-
|
|
328
|
-
|
|
339
|
+
if (!rawType) {
|
|
340
|
+
// A permission-edit row `{activityCode, propertyPermissions}` targets an
|
|
341
|
+
// EXISTING draft node and inherits its type at merge time — demanding a
|
|
342
|
+
// type here forced agents to re-send identity fields, and any spelling
|
|
343
|
+
// drift then minted `_2` codes. Bare new nodes still need a type.
|
|
344
|
+
const targetsExistingNode = activity?.propertyPermissions !== undefined || activity?.subPropertyPermissions !== undefined || activity?.activityName !== undefined || activity?.name !== undefined || activity?.i18nMessage !== undefined || activity?.permittedAction !== undefined || activity?.buttonPermission !== undefined;
|
|
345
|
+
if (!targetsExistingNode)
|
|
346
|
+
throw new ToolRefusalError({ status: "refused", blocker: "workflow-activity-type-missing", message: `${tool}: activities[${index}] requires type/activityType(权限编辑行带 propertyPermissions 时可省——类型继承自草稿同名节点)` });
|
|
347
|
+
}
|
|
348
|
+
if (rawType !== "" && !WORKFLOW_ACTIVITY_TYPES.has(type))
|
|
329
349
|
throw new ToolRefusalError({ status: "refused", blocker: "workflow-render-activity-type-invalid", message: `${tool}: activities[${index}] has an illegal ActivityType` });
|
|
330
350
|
codes.add(code);
|
|
331
351
|
}
|
|
@@ -688,9 +708,9 @@ function completeWorkflowChain(rules, activities) {
|
|
|
688
708
|
const byEdge = new Map(rules.map((rule) => [`${rule.preActivityCode}->${rule.postActivityCode}`, rule]));
|
|
689
709
|
return codes.slice(0, -1).map((code, index) => byEdge.get(`${code}->${codes[index + 1]}`) ?? { preActivityCode: code, postActivityCode: codes[index + 1] });
|
|
690
710
|
}
|
|
691
|
-
/** Activity type resolution shared by the merge + code reconcile (APPROVAL is a legacy alias). */
|
|
711
|
+
/** Activity type resolution shared by the merge + code reconcile (APPROVAL is a legacy alias; case-insensitive — "Participant"/"approval" are the same node type as the draft's "PARTICIPANT" and must not mint a fresh code). */
|
|
692
712
|
function activityTypeOf(record) {
|
|
693
|
-
const rawType = String(record.activityType ?? record.type ?? "");
|
|
713
|
+
const rawType = String(record.activityType ?? record.type ?? "").trim().toUpperCase();
|
|
694
714
|
return rawType === "APPROVAL" ? "PARTICIPANT" : rawType;
|
|
695
715
|
}
|
|
696
716
|
/** Display name the caller asked for, in any accepted spelling. */
|
|
@@ -747,9 +767,16 @@ function reconcileCallerActivityCodes(callerActivities, draftActivities) {
|
|
|
747
767
|
const draft = draftByCode.get(original);
|
|
748
768
|
let code = original;
|
|
749
769
|
if (draft !== undefined) {
|
|
750
|
-
const draftType = String(draft.activityType ?? "");
|
|
770
|
+
const draftType = String(draft.activityType ?? "").trim().toUpperCase() === "APPROVAL" ? "PARTICIPANT" : String(draft.activityType ?? "").trim().toUpperCase();
|
|
751
771
|
const draftName = activityNameOf(draft);
|
|
752
|
-
|
|
772
|
+
// Only a field the caller ACTUALLY supplied can constitute a change: a
|
|
773
|
+
// delta edit that re-sends `{activityCode, propertyPermissions}` (no
|
|
774
|
+
// type/name) inherits the draft's identity — minting `_2` codes for it
|
|
775
|
+
// churned the code on every edit (Activity3 → Activity3_2 →
|
|
776
|
+
// Activity3_2_2). An omitted type is "keep the stored one", not "changed
|
|
777
|
+
// to empty".
|
|
778
|
+
const callerHasType = record.activityType !== undefined || record.type !== undefined;
|
|
779
|
+
const changed = (callerHasType && draftType !== type) || (name !== undefined && draftName !== undefined && name !== draftName);
|
|
753
780
|
if (changed) {
|
|
754
781
|
let suffix = 2;
|
|
755
782
|
while (used.has(`${original}_${suffix}`))
|
|
@@ -836,6 +863,135 @@ function deepMergeActivityRow(base, caller) {
|
|
|
836
863
|
merged.propertyPermissions = mergePermissionsByCode(base.propertyPermissions, caller.propertyPermissions);
|
|
837
864
|
return merged;
|
|
838
865
|
}
|
|
866
|
+
/**
|
|
867
|
+
* Live 2026-10-14 (designer-template parity): flows created/updated through
|
|
868
|
+
* the API miss three defaults the designer writes on its first save, and the
|
|
869
|
+
* node config visibly drifts in the UI:
|
|
870
|
+
*
|
|
871
|
+
* 1. the FILL node ({Originator}) carries an EMPTY buttonPermission
|
|
872
|
+
* .childTableBtns — the designer pre-checks ADD/DELETE/IMPORT/EXPORT for
|
|
873
|
+
* EVERY child-table container (BATCH_UPDATE stays disabled);
|
|
874
|
+
* 2. the fill node keeps the auto-draft showReject:true — the designer
|
|
875
|
+
* default is false (the originator cannot reject their own submission);
|
|
876
|
+
* 3. approval nodes keep rejectToStart:false — the designer default is true
|
|
877
|
+
* (reject goes back to the originator node).
|
|
878
|
+
*
|
|
879
|
+
* Backfill applies ONLY to keys the caller did not explicitly send for that
|
|
880
|
+
* node in this update, so an explicit permittedAction/buttonPermission always
|
|
881
|
+
* wins.
|
|
882
|
+
*/
|
|
883
|
+
/**
|
|
884
|
+
* Child-table fields of a schema, draft face first (the field lane's own
|
|
885
|
+
* draft-else-published semantics: after field.publish the draft face may be
|
|
886
|
+
* empty while the published face carries the row).
|
|
887
|
+
*/
|
|
888
|
+
async function childTableFieldsOf(session, schemaCode) {
|
|
889
|
+
const read = async (isPublish) => {
|
|
890
|
+
try {
|
|
891
|
+
return wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode, isPublish } })).data);
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
return [];
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
const rows = (await read("false")).length > 0 ? await read("false") : await read("true");
|
|
898
|
+
return rows
|
|
899
|
+
.map((row) => ({ code: String(row.code ?? row.propertyCode ?? ""), name: String(row.name ?? row.code ?? ""), isChild: Number(row.type ?? row.propertyType ?? 0) === 8 || String(row.type ?? row.propertyType ?? "").toUpperCase() === "CHILD_TABLE" || row.subSchema !== undefined }))
|
|
900
|
+
.filter((row) => row.isChild && row.code !== "");
|
|
901
|
+
}
|
|
902
|
+
const CHILD_TABLE_LIST_BUTTONS = [
|
|
903
|
+
{ code: "ADD", name: "新增", enabled: true },
|
|
904
|
+
{ code: "DELETE", name: "删除", enabled: true },
|
|
905
|
+
{ code: "IMPORT", name: "导入", enabled: true },
|
|
906
|
+
{ code: "EXPORT", name: "导出", enabled: true },
|
|
907
|
+
{ code: "BATCH_UPDATE", name: "批量编辑", enabled: false },
|
|
908
|
+
];
|
|
909
|
+
function isOriginatorFillNode(activity) {
|
|
910
|
+
return /\{originator\}/i.test(String(activity.participant ?? ""));
|
|
911
|
+
}
|
|
912
|
+
async function backfillDesignerDefaults(session, schemaCode, body, callerTemplate) {
|
|
913
|
+
const activities = Array.isArray(body.activities) ? body.activities : [];
|
|
914
|
+
const participants = activities.filter((activity) => String(activity.activityType ?? "") === "PARTICIPANT");
|
|
915
|
+
if (participants.length === 0)
|
|
916
|
+
return;
|
|
917
|
+
// Caller rows by FINAL activityCode: keys the caller explicitly sent for a
|
|
918
|
+
// node must not be overwritten by the backfill.
|
|
919
|
+
const callerRows = new Map();
|
|
920
|
+
for (const row of (callerActivities(callerTemplate) ?? [])) {
|
|
921
|
+
const code = activityCodeOf(row);
|
|
922
|
+
if (code)
|
|
923
|
+
callerRows.set(code, row);
|
|
924
|
+
}
|
|
925
|
+
const childTables = await childTableFieldsOf(session, schemaCode);
|
|
926
|
+
for (const activity of participants) {
|
|
927
|
+
const code = String(activity.activityCode ?? "");
|
|
928
|
+
const callerRow = callerRows.get(code);
|
|
929
|
+
const fill = isOriginatorFillNode(activity);
|
|
930
|
+
// 2/3: reject-action designer defaults (fill: cannot reject self;
|
|
931
|
+
// approval: reject returns to the originator node).
|
|
932
|
+
// The auto-draft PRESENCE of these keys is not designer intent — it ships
|
|
933
|
+
// showReject:true / rejectToStart:false everywhere. Overwrite toward the
|
|
934
|
+
// designer default unless THIS update explicitly sent permittedAction for
|
|
935
|
+
// the node.
|
|
936
|
+
const action = (activity.permittedAction ?? {});
|
|
937
|
+
if (callerRow?.permittedAction === undefined) {
|
|
938
|
+
if (fill)
|
|
939
|
+
activity.permittedAction = { ...action, showReject: false, rejectToStart: false };
|
|
940
|
+
else
|
|
941
|
+
activity.permittedAction = { ...action, rejectToStart: true };
|
|
942
|
+
}
|
|
943
|
+
// 1: child-table list buttons on the fill node only (designer parity:
|
|
944
|
+
// approval nodes keep childTableBtns empty).
|
|
945
|
+
if (fill && !(callerRow?.buttonPermission !== undefined) && childTables.length > 0) {
|
|
946
|
+
const buttonPermission = (activity.buttonPermission ?? {});
|
|
947
|
+
const existing = Array.isArray(buttonPermission.childTableBtns) ? buttonPermission.childTableBtns : [];
|
|
948
|
+
if (existing.length === 0) {
|
|
949
|
+
activity.buttonPermission = {
|
|
950
|
+
...buttonPermission,
|
|
951
|
+
containerCode: buttonPermission.containerCode ?? schemaCode,
|
|
952
|
+
customBtns: buttonPermission.customBtns ?? [],
|
|
953
|
+
childTableBtns: childTables.map((table) => ({
|
|
954
|
+
containerCode: table.code,
|
|
955
|
+
containerName: table.name,
|
|
956
|
+
customBtns: CHILD_TABLE_LIST_BUTTONS.map((button) => ({
|
|
957
|
+
code: button.code,
|
|
958
|
+
name: button.name,
|
|
959
|
+
name_i18n: { zh: button.name, zh_TW: button.name, en: button.name },
|
|
960
|
+
disabled: !button.enabled,
|
|
961
|
+
type: "list",
|
|
962
|
+
enabled: button.enabled,
|
|
963
|
+
})),
|
|
964
|
+
})),
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Live 2026-10-14: a workflow node's propertyPermissions ACCEPTS
|
|
972
|
+
* `required:true` on a CHILD_TABLE (子表) property at the wire level, but the
|
|
973
|
+
* designer refuses that shape — the node's field config cannot render/必填 a
|
|
974
|
+
* sub-table control. Fail closed BEFORE the write (we never store a shape the
|
|
975
|
+
* designer cannot represent); only CALLER-supplied rows are inspected, so an
|
|
976
|
+
* inherited draft row never blocks an unrelated edit. The field-list read is
|
|
977
|
+
* lazy: it fires only when the caller actually sets required somewhere.
|
|
978
|
+
*/
|
|
979
|
+
async function refuseRequiredOnChildTableFields(session, schemaCode, template, tool) {
|
|
980
|
+
const rows = (callerActivities(template) ?? []).flatMap((activity) => (Array.isArray(activity.propertyPermissions) ? activity.propertyPermissions : []));
|
|
981
|
+
const requiredCodes = [...new Set(rows.filter((row) => row?.required === true).map((row) => String(row.propertyCode ?? "")))].filter((code) => code !== "");
|
|
982
|
+
if (requiredCodes.length === 0)
|
|
983
|
+
return;
|
|
984
|
+
const childTables = await childTableFieldsOf(session, schemaCode);
|
|
985
|
+
const hits = requiredCodes.filter((code) => childTables.some((table) => table.code === code));
|
|
986
|
+
if (hits.length > 0)
|
|
987
|
+
throw new ToolRefusalError({
|
|
988
|
+
status: "refused",
|
|
989
|
+
blocker: "workflow-child-table-not-required",
|
|
990
|
+
schemaCode,
|
|
991
|
+
propertyCodes: hits,
|
|
992
|
+
message: `${tool}: 子表字段不能配置为必填(propertyPermissions.required)——接口形状接受但设计器节点字段配置不支持(子表控件无必填语义),存入后节点配置无法正常渲染。命中的子表字段:${hits.join("、")}。子表行只配置 visible/editable(summary 工具自动补);字段级必填请配到子表内部字段所在节点或改数据规则校验`,
|
|
993
|
+
});
|
|
994
|
+
}
|
|
839
995
|
/**
|
|
840
996
|
* Incremental (`merge:true`) workflow edit: the caller sends ONLY the activities/
|
|
841
997
|
* routes it adds or changes; the draft is the base. Draft activities survive
|
|
@@ -896,6 +1052,18 @@ function mergeCallerDelta(template, draft) {
|
|
|
896
1052
|
return { ...activity, ...override };
|
|
897
1053
|
});
|
|
898
1054
|
const newActivities = [...callerByOriginal.values()];
|
|
1055
|
+
// A caller-only code that arrived WITHOUT a type has no draft row to inherit
|
|
1056
|
+
// from — refuse it here (the lint allows type-less permission-shape rows
|
|
1057
|
+
// precisely because they are expected to match an existing node).
|
|
1058
|
+
for (const activity of newActivities) {
|
|
1059
|
+
const type = activityTypeOf(activity);
|
|
1060
|
+
if (type === "")
|
|
1061
|
+
throw new ToolRefusalError({
|
|
1062
|
+
status: "refused",
|
|
1063
|
+
blocker: "workflow-activity-type-missing",
|
|
1064
|
+
message: `workflow.update: 新节点 ${String(activity.activityCode ?? "")} 缺 type/activityType(权限编辑行可省类型仅当 activityCode 命中草稿既有节点)`,
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
899
1067
|
// Insert added nodes BEFORE the terminal END, so the auto-chain reads
|
|
900
1068
|
// START → …existing… → new → END (appending after END produced a chain that
|
|
901
1069
|
// ended on the new node instead of END).
|
|
@@ -1021,6 +1189,53 @@ function normalizeWorkflowTemplate(template, draft) {
|
|
|
1021
1189
|
* 50000 (live incident). Caller keys override; everything else echoes the
|
|
1022
1190
|
* draft verbatim (the same merge pattern workflow.bindRule uses).
|
|
1023
1191
|
*/
|
|
1192
|
+
/**
|
|
1193
|
+
* Rollback specs (git-revert semantics): before-state = the full draft row
|
|
1194
|
+
* captured via get_draft; the restore replays workflow.update with the
|
|
1195
|
+
* before template as a full-row replace (mergedTemplateBody echoes the draft
|
|
1196
|
+
* verbatim when the caller template IS the draft row).
|
|
1197
|
+
*/
|
|
1198
|
+
function workflowRollbackSpec(toolName) {
|
|
1199
|
+
return {
|
|
1200
|
+
target: (args) => `workflow:${String(args.schemaCode ?? "")}/${String(args.code ?? args.workflowCode ?? "")}`,
|
|
1201
|
+
read: async (session, args) => {
|
|
1202
|
+
const code = String(args.code ?? args.workflowCode ?? "") || (await resolveWorkflowArg(session, { workflowCode: undefined, schemaCode: args.schemaCode }) ?? undefined);
|
|
1203
|
+
if (!code)
|
|
1204
|
+
return null;
|
|
1205
|
+
return getWorkflowDraftOrNull(session, code);
|
|
1206
|
+
},
|
|
1207
|
+
inverse: ({ before, args, readback }) => {
|
|
1208
|
+
const schemaCode = String(args.schemaCode ?? "");
|
|
1209
|
+
const code = String(args.code ?? args.workflowCode ?? "");
|
|
1210
|
+
if (toolName === "workflow.create") {
|
|
1211
|
+
if (readback.alreadyPresent === true)
|
|
1212
|
+
return { kind: "calls", calls: [], notes: ["create 时已存在(幂等跳过),未写入,无需回滚"] };
|
|
1213
|
+
return { kind: "calls", calls: [{ tool: "workflow.delete", args: { schemaCode, code } }] };
|
|
1214
|
+
}
|
|
1215
|
+
if (toolName === "workflow.delete") {
|
|
1216
|
+
if (before == null)
|
|
1217
|
+
return { kind: "calls", calls: [], notes: ["delete 前目标已缺席(幂等),无需回滚"] };
|
|
1218
|
+
const row = before;
|
|
1219
|
+
return {
|
|
1220
|
+
kind: "calls",
|
|
1221
|
+
calls: [
|
|
1222
|
+
{ tool: "workflow.create", args: { schemaCode, code, name: String(row.workflowName ?? row.name ?? code) } },
|
|
1223
|
+
{ tool: "workflow.update", args: { schemaCode, code, template: row, merge: false } },
|
|
1224
|
+
],
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
if (toolName === "workflow.publish")
|
|
1228
|
+
return {
|
|
1229
|
+
kind: "irreversible",
|
|
1230
|
+
reason: "平台无 unpublish:发布是生命周期推进(workflowVersion 递增),不可参数重放撤销。内容回退路径:对该流程前一 workflow.update/bindRule 的 before 执行 rollback plan/apply 恢复草稿内容后,重新 workflow.publish",
|
|
1231
|
+
};
|
|
1232
|
+
// update / bindRule:整模板替换回 before 草稿
|
|
1233
|
+
if (before == null)
|
|
1234
|
+
return { kind: "irreversible", reason: "before 快照为空(流程不存在),无内容可恢复" };
|
|
1235
|
+
return { kind: "calls", calls: [{ tool: "workflow.update", args: { schemaCode, code, template: before, merge: false } }] };
|
|
1236
|
+
},
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1024
1239
|
async function mergedTemplateBody(session, schemaCode, code, template, merge = false) {
|
|
1025
1240
|
const draft = await getWorkflowDraftOrNull(session, code);
|
|
1026
1241
|
if (draft == null)
|
|
@@ -1391,13 +1606,14 @@ export function registerWorkflowLane(tools) {
|
|
|
1391
1606
|
performed: { errcode: performed.errcode },
|
|
1392
1607
|
readback: { workflowCode: args.code, headerPresent: true, draftPresent: draft != null, templateId: draft?.id ?? null },
|
|
1393
1608
|
};
|
|
1394
|
-
});
|
|
1609
|
+
}, undefined, undefined, workflowRollbackSpec("workflow.create"));
|
|
1395
1610
|
writeTool(tools, "workflow.update", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.update"].scope, WORKFLOW_TOOLS["workflow.update"].description, { schemaCode: z.string().min(1), code: z.string().min(1), template: workflowTemplateArg, merge: z.boolean().optional().describe("增量:只发改动/新增的活动与路由,工具按 activityCode 合并到草稿(草稿为基;新增节点自动接链),不必先读回整模板再重发。默认自动:模板缺 START 或 END 时按增量 merge(部分模板),给了完整 START..END 才整模板替换;显式 true 强制增量、false 强制替换") }, async (session, args) => {
|
|
1396
1611
|
const template = args.template ?? {};
|
|
1397
1612
|
validateCallerActivities(template, "workflow.update");
|
|
1398
1613
|
validateWorkflowRouteConditions(template, "workflow.update");
|
|
1399
1614
|
assertTemplateNotMinimal(template, "workflow.update");
|
|
1400
1615
|
lintWorkflowTemplate(template, "workflow.update");
|
|
1616
|
+
await refuseRequiredOnChildTableFields(session, args.schemaCode, template, "workflow.update");
|
|
1401
1617
|
// Ticket 112: merge onto the full draft row — a verbatim minimal post
|
|
1402
1618
|
// leaves converter holes and answers errcode 50000 on the platform.
|
|
1403
1619
|
// Delta semantics: explicit `true` forces merge, explicit `false` forces
|
|
@@ -1405,6 +1621,7 @@ export function registerWorkflowLane(tools) {
|
|
|
1405
1621
|
// START/END) as a delta — see activitiesLackBoundaries.
|
|
1406
1622
|
const merge = args.merge === true || (args.merge !== false && activitiesLackBoundaries(template));
|
|
1407
1623
|
const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, merge);
|
|
1624
|
+
await backfillDesignerDefaults(session, args.schemaCode, body, template);
|
|
1408
1625
|
validateWorkflowRender(body, "workflow.update");
|
|
1409
1626
|
// Live: workflow/update does NOT run the template validator — it stores a
|
|
1410
1627
|
// refused participant silently and only /publish answers the
|
|
@@ -1417,7 +1634,11 @@ export function registerWorkflowLane(tools) {
|
|
|
1417
1634
|
validateWorkflowRender(data, "workflow.update readback");
|
|
1418
1635
|
const participantWarnings = participantReferenceWarnings(data);
|
|
1419
1636
|
return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, activities: Array.isArray(data.activities) ? data.activities.length : 0, templateType: data.templateType ?? null, ...(participantWarnings.length ? { participantWarnings } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
|
|
1420
|
-
})
|
|
1637
|
+
}, (args) => {
|
|
1638
|
+
// Zero-wire refusal (ticket 77): fires in semanticGate BEFORE the
|
|
1639
|
+
// governed pipeline — including the rollback before-capture read.
|
|
1640
|
+
assertTemplateNotMinimal(args.template ?? {}, "workflow.update");
|
|
1641
|
+
}, undefined, workflowRollbackSpec("workflow.update"));
|
|
1421
1642
|
writeTool(tools, "workflow.publish", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.publish"].scope, WORKFLOW_TOOLS["workflow.publish"].description, { schemaCode: z.string().min(1), code: z.string().min(1), template: workflowTemplateArg.optional() }, async (session, args) => {
|
|
1422
1643
|
// An absent template publishes the stored draft as-is — the platform's own
|
|
1423
1644
|
// path. The schema is optional so the contract no longer contradicts it.
|
|
@@ -1425,10 +1646,12 @@ export function registerWorkflowLane(tools) {
|
|
|
1425
1646
|
validateCallerActivities(template, "workflow.publish");
|
|
1426
1647
|
validateWorkflowRouteConditions(template, "workflow.publish");
|
|
1427
1648
|
assertTemplateNotMinimal(template, "workflow.publish");
|
|
1649
|
+
await refuseRequiredOnChildTableFields(session, args.schemaCode, template, "workflow.publish");
|
|
1428
1650
|
lintWorkflowTemplate(template, "workflow.publish");
|
|
1429
1651
|
// Ticket 112: same merge-before-write as update. A template without both
|
|
1430
1652
|
// boundaries is a partial edit → delta merge (see activitiesLackBoundaries).
|
|
1431
1653
|
const body = await mergedTemplateBody(session, args.schemaCode, args.code, template, activitiesLackBoundaries(template));
|
|
1654
|
+
await backfillDesignerDefaults(session, args.schemaCode, body, template);
|
|
1432
1655
|
validateWorkflowRender(body, "workflow.publish");
|
|
1433
1656
|
const performed = await wireCall(session, { path: ENDPOINTS.workflowPublish, method: "POST", body });
|
|
1434
1657
|
// A refused publish answers errcode 0 — the ValidateMessage[] is the truth.
|
|
@@ -1454,7 +1677,9 @@ export function registerWorkflowLane(tools) {
|
|
|
1454
1677
|
validateWorkflowRender(data, "workflow.publish readback");
|
|
1455
1678
|
const participantWarnings = participantReferenceWarnings(data);
|
|
1456
1679
|
return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, published: isPublished, workflowVersion: data.workflowVersion ?? null, ...(warnings.length ? { warnings } : {}), ...(participantWarnings.length ? { participantWarnings } : {}), workflowRenderCompat: workflowRenderCompat(data) } };
|
|
1457
|
-
})
|
|
1680
|
+
}, (args) => {
|
|
1681
|
+
assertTemplateNotMinimal(args.template ?? {}, "workflow.publish");
|
|
1682
|
+
}, undefined, workflowRollbackSpec("workflow.publish"));
|
|
1458
1683
|
writeTool(tools, "workflow.bindRule", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.bindRule"].scope, WORKFLOW_TOOLS["workflow.bindRule"].description, {
|
|
1459
1684
|
schemaCode: z.string().min(1),
|
|
1460
1685
|
workflowCode: z.string().min(1),
|
|
@@ -1577,7 +1802,7 @@ export function registerWorkflowLane(tools) {
|
|
|
1577
1802
|
if (codes.length === 0 || new Set(codes.map(String)).size !== codes.length || codes.some((code) => typeof code !== "string" || code.length === 0))
|
|
1578
1803
|
throw new ToolRefusalError({ status: "refused", blocker: "workflow-bindrule-codes-invalid", message: "ruleCodes 必须是非空、去重的规则 code 数组" });
|
|
1579
1804
|
}
|
|
1580
|
-
});
|
|
1805
|
+
}, undefined, workflowRollbackSpec("workflow.bindRule"));
|
|
1581
1806
|
writeTool(tools, "workflow.delete", WORKFLOW_LANE, WORKFLOW_TOOLS["workflow.delete"].scope, WORKFLOW_TOOLS["workflow.delete"].description, { schemaCode: z.string().min(1), code: z.string().min(1) }, async (session, args) => {
|
|
1582
1807
|
const performed = await wireCall(session, {
|
|
1583
1808
|
path: ENDPOINTS.workflowDelete,
|
|
@@ -1586,7 +1811,7 @@ export function registerWorkflowLane(tools) {
|
|
|
1586
1811
|
});
|
|
1587
1812
|
const header = await wireCall(session, { path: ENDPOINTS.workflowGetHeader, query: { workflowCode: args.code } }).catch(() => ({ data: null }));
|
|
1588
1813
|
return { performed: { errcode: performed.errcode }, readback: { workflowCode: args.code, absent: header.data === null || header.data === undefined } };
|
|
1589
|
-
});
|
|
1814
|
+
}, undefined, undefined, workflowRollbackSpec("workflow.delete"));
|
|
1590
1815
|
// ---- high-level greenfield orchestration (round 10 ergonomics) --------
|
|
1591
1816
|
// The survey's write-face gap: a first workflow took create → update →
|
|
1592
1817
|
// publish (+ bindRule for rules), 3–4 separate CLI calls. configure_workflow
|
package/dist/online-js-source.js
CHANGED
|
@@ -245,12 +245,49 @@ export function composeCustomScript(existing, incoming, mode) {
|
|
|
245
245
|
// Merge into a lifecycle body: seed one when the existing script has none.
|
|
246
246
|
let base = hasLifecycleWrapper(trimmedExisting) ? existing : wrapInLifecycle(existing);
|
|
247
247
|
for (const block of splitRegistrations(incoming)) {
|
|
248
|
-
if (block.event !== null)
|
|
248
|
+
if (block.event !== null) {
|
|
249
249
|
base = removeRegistration(base, block.event);
|
|
250
|
+
base = insertIntoLifecycleBody(base, block.text);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
// Idempotent re-send (live 2026-10-14 中冶): re-composing the same file
|
|
254
|
+
// used to stack a SECOND copy of the header comment + var preamble on
|
|
255
|
+
// every pass. A non-registration block that already lives in the script
|
|
256
|
+
// verbatim is a no-op, not another append.
|
|
257
|
+
if (base.includes(block.text))
|
|
258
|
+
continue;
|
|
259
|
+
// A block that OPENS with the same header comment as the existing script
|
|
260
|
+
// but differs elsewhere is a whole-file REVISION: appending would
|
|
261
|
+
// duplicate the preamble AND leave the old body active. Fail closed and
|
|
262
|
+
// route to the replace path instead of guessing a textual merge.
|
|
263
|
+
const header = leadingCommentOf(block.text);
|
|
264
|
+
if (header !== null && header.length >= 16 && base.includes(header))
|
|
265
|
+
throw new ToolRefusalError({
|
|
266
|
+
status: "refused",
|
|
267
|
+
blocker: "onlinejs-revision-replace-required",
|
|
268
|
+
message: "传入源与既有脚本同头注释(同一份脚本文件的修订版)——merge 会把注释/变量块重复追加且旧逻辑仍生效。整文件修订请:onlinejs.get 取回现脚本 → 修改 → onlinejs.compose replace:true 传完整 (function(form){…}) 脚本;只追加新逻辑则去掉与既有脚本重复的头注释/变量块后再发",
|
|
269
|
+
});
|
|
250
270
|
base = insertIntoLifecycleBody(base, block.text);
|
|
251
271
|
}
|
|
252
272
|
return base;
|
|
253
273
|
}
|
|
274
|
+
/** The first /** … *\/ or leading // comment run of `text`, verbatim; null when the text does not open with one. */
|
|
275
|
+
function leadingCommentOf(text) {
|
|
276
|
+
const trimmed = text.trimStart();
|
|
277
|
+
if (trimmed.startsWith("/*")) {
|
|
278
|
+
const end = trimmed.indexOf("*/");
|
|
279
|
+
if (end > 0)
|
|
280
|
+
return trimmed.slice(0, end + 2);
|
|
281
|
+
}
|
|
282
|
+
const lines = [];
|
|
283
|
+
for (const line of trimmed.split("\n")) {
|
|
284
|
+
if (line.trimStart().startsWith("//"))
|
|
285
|
+
lines.push(line);
|
|
286
|
+
else
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
return lines.length > 0 ? lines.join("\n") : null;
|
|
290
|
+
}
|
|
254
291
|
export function assertPlatformOnlineJsComposition() {
|
|
255
292
|
const source = "form.on('onLoad', function(){});";
|
|
256
293
|
const composed = composeCustomScript("", source, "merge");
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field-level deep diff for the plan surface. Returns at most `limit`
|
|
3
|
+
* changed paths plus the total count; arrays are compared by index
|
|
4
|
+
* (length change is reported on the array path itself).
|
|
5
|
+
*/
|
|
6
|
+
export function deepDiff(before, current, path = "", limit = 40, acc = []) {
|
|
7
|
+
const walk = (b, c, p) => {
|
|
8
|
+
if (acc.length >= limit)
|
|
9
|
+
return;
|
|
10
|
+
if (b === null && c === null)
|
|
11
|
+
return;
|
|
12
|
+
const bothObject = b !== null && c !== null && typeof b === "object" && typeof c === "object";
|
|
13
|
+
if (!bothObject) {
|
|
14
|
+
if (b !== c && !(Number.isNaN(b) && Number.isNaN(c))) {
|
|
15
|
+
if (typeof b === "string" || typeof c === "string") {
|
|
16
|
+
const bp = tryParseJson(b);
|
|
17
|
+
const cp = tryParseJson(c);
|
|
18
|
+
if (bp.ok && cp.ok)
|
|
19
|
+
return walk(bp.value, cp.value, p);
|
|
20
|
+
}
|
|
21
|
+
acc.push({ path: p, before: b, current: c });
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const bArr = Array.isArray(b);
|
|
26
|
+
const cArr = Array.isArray(c);
|
|
27
|
+
if (bArr || cArr) {
|
|
28
|
+
if (!bArr || !cArr) {
|
|
29
|
+
acc.push({ path: p, before: b, current: c });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const bA = b;
|
|
33
|
+
const cA = c;
|
|
34
|
+
if (bA.length !== cA.length)
|
|
35
|
+
acc.push({ path: p, before: `<array×${bA.length}>`, current: `<array×${cA.length}>` });
|
|
36
|
+
const shared = Math.min(bA.length, cA.length);
|
|
37
|
+
for (let i = 0; i < shared && acc.length < limit; i += 1)
|
|
38
|
+
walk(bA[i], cA[i], `${p}[${i}]`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const bRec = b;
|
|
42
|
+
const cRec = c;
|
|
43
|
+
for (const key of new Set([...Object.keys(bRec), ...Object.keys(cRec)])) {
|
|
44
|
+
if (acc.length >= limit)
|
|
45
|
+
return;
|
|
46
|
+
const inB = key in bRec;
|
|
47
|
+
const inC = key in cRec;
|
|
48
|
+
if (!inB || !inC) {
|
|
49
|
+
acc.push({
|
|
50
|
+
path: joinPath(p, key),
|
|
51
|
+
before: inB ? bRec[key] : "<absent>",
|
|
52
|
+
current: inC ? cRec[key] : "<absent>",
|
|
53
|
+
});
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
walk(bRec[key], cRec[key], joinPath(p, key));
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
walk(before, current, path);
|
|
60
|
+
return { changes: acc, total: acc.length };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Canonical projection for restore verification: a replayed restore will
|
|
64
|
+
* NEVER be byte-equal to the before snapshot — the platform stamps
|
|
65
|
+
* modifiedTime/workflowVersion, geometry and selection state live on
|
|
66
|
+
* activities/rules, and a delete→recreate cycle mints fresh ids. Verification
|
|
67
|
+
* therefore compares the durable CONFIG content with these volatile keys
|
|
68
|
+
* dropped at every depth.
|
|
69
|
+
*/
|
|
70
|
+
const VOLATILE_VERIFY_KEYS = new Set([
|
|
71
|
+
"id",
|
|
72
|
+
"modifiedTime",
|
|
73
|
+
"createdTime",
|
|
74
|
+
"createdBy",
|
|
75
|
+
"modifiedBy",
|
|
76
|
+
"deleted",
|
|
77
|
+
"workflowVersion",
|
|
78
|
+
"published",
|
|
79
|
+
"isSelected",
|
|
80
|
+
"icon",
|
|
81
|
+
"x",
|
|
82
|
+
"y",
|
|
83
|
+
"width",
|
|
84
|
+
"height",
|
|
85
|
+
"points",
|
|
86
|
+
"textPosition",
|
|
87
|
+
"fixedPoint",
|
|
88
|
+
"shortCode",
|
|
89
|
+
]);
|
|
90
|
+
export function canonicalForVerify(value) {
|
|
91
|
+
if (Array.isArray(value))
|
|
92
|
+
return value.map(canonicalForVerify);
|
|
93
|
+
if (value !== null && typeof value === "object") {
|
|
94
|
+
const record = value;
|
|
95
|
+
const out = {};
|
|
96
|
+
for (const key of Object.keys(record).sort()) {
|
|
97
|
+
if (VOLATILE_VERIFY_KEYS.has(key))
|
|
98
|
+
continue;
|
|
99
|
+
out[key] = canonicalForVerify(record[key]);
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
function joinPath(base, key) {
|
|
106
|
+
return base === "" ? key : `${base}.${key}`;
|
|
107
|
+
}
|
|
108
|
+
function tryParseJson(value) {
|
|
109
|
+
if (typeof value !== "string")
|
|
110
|
+
return { ok: false };
|
|
111
|
+
const trimmed = value.trim();
|
|
112
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("["))
|
|
113
|
+
return { ok: false };
|
|
114
|
+
try {
|
|
115
|
+
return { ok: true, value: JSON.parse(trimmed) };
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return { ok: false };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=rollback-spec.js.map
|