openxiangda 1.0.176 → 1.0.178

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/lib/cli.js CHANGED
@@ -82,6 +82,10 @@ const {
82
82
  writeContentCache,
83
83
  } = require('./content-cache');
84
84
  const { explainReleaseExecution } = require('./release-explain');
85
+ const {
86
+ classifyReleaseExecutionFailure,
87
+ isDefinitelyPreWriteReleaseError,
88
+ } = require('./release-error-classification');
85
89
  const { buildTaskStatus, findAppReleaseId } = require('./task-status');
86
90
  const {
87
91
  checkEngineeringPolicyMarkers,
@@ -94,6 +98,10 @@ const {
94
98
  getImpactedResourceTargets,
95
99
  validateManifestSourceBindings,
96
100
  } = require('./source-dependencies');
101
+ const {
102
+ collectFormSnapshotFieldIds,
103
+ validateFormFieldContracts,
104
+ } = require('./form-field-contract');
97
105
  const { getSkillStatusReport, installSkills } = require('./skills');
98
106
  const {
99
107
  assertOrClaimWorktreeOwner,
@@ -170,13 +178,32 @@ let readOnlyHttpGuardDepth = 0;
170
178
  let readOnlyHttpGuardLabel = null;
171
179
  let activePublishRequestContext = null;
172
180
  let activePublishLeaseHeartbeat = null;
181
+ let activeCommandScopedPublishLease = null;
173
182
  const genericGitBasePlanCache = new Map();
174
183
 
175
184
  async function main(argv) {
185
+ let commandError = null;
176
186
  try {
177
187
  return await mainImpl(argv);
188
+ } catch (error) {
189
+ commandError = error;
190
+ throw error;
178
191
  } finally {
179
192
  await stopActivePublishLeaseHeartbeat();
193
+ try {
194
+ await finalizeCommandScopedPublishLease({
195
+ succeeded: commandError === null,
196
+ });
197
+ } catch (cleanupError) {
198
+ if (!commandError) throw cleanupError;
199
+ commandError.publishLeaseCleanupError = {
200
+ code: cleanupError?.code || 'PUBLISH_LEASE_CLEANUP_FAILED',
201
+ message: maskText(cleanupError?.message || String(cleanupError)),
202
+ };
203
+ warn(
204
+ `发布命令失败后的租约清理也未完成:${commandError.publishLeaseCleanupError.message}`
205
+ );
206
+ }
180
207
  activePublishRequestContext = null;
181
208
  }
182
209
  }
@@ -686,7 +713,7 @@ async function sdd(args) {
686
713
  ' openxiangda sdd quick tune-customer-copy --kind copy --pages customer_list --files src/pages/customer_list/index.tsx --summary "用户确认小范围文案调整"',
687
714
  ' openxiangda sdd approve add-customer-list --summary "用户确认设计"',
688
715
  ' openxiangda sdd ready add-customer-list',
689
- ' openxiangda sdd bundle mainline-release --changes add-customer-list,fix-order-log',
716
+ ' openxiangda sdd bundle mainline-release --changes add-customer-list,fix-order-log [--source-base-ref <commit>]',
690
717
  ' openxiangda sdd context --change add-customer-list --json',
691
718
  ' openxiangda sdd verify --change add-customer-list --stage prepublish --json',
692
719
  ' openxiangda sdd render add-customer-list # 仅在确实需要完整文档时',
@@ -701,6 +728,7 @@ async function sdd(args) {
701
728
  ' - verify stage 可选 implementation、prepublish、postpublish、archive;不传保持旧版全量校验。',
702
729
  ' - 默认 streamlined 校验只把 approval、精确资源/文件范围、实际发布命令作为硬门禁;任务、证据和规格文案仅告警。配置 strictDocumentation: true 可恢复严格文档门禁。',
703
730
  ' - bundle 聚合多个 approved change;提交并推送该 bundle 后,在权威主分支执行一次 staged + atomic 发布。',
731
+ ' - bundle 默认保留来源 change 创建时的共同 Git 基线;仅迁移旧 change 缺少 sourceBase 时使用 --source-base-ref 显式补充。',
704
732
  ' - 新 release 只能从已推送且与权威远端默认主分支一致的 clean HEAD 开始。',
705
733
  ].join('\n'));
706
734
  return;
@@ -781,6 +809,7 @@ async function sdd(args) {
781
809
  changes: flags.changes,
782
810
  integration,
783
811
  profile: flags.profile,
812
+ sourceBaseRef: flags['source-base-ref'],
784
813
  force: Boolean(flags.force),
785
814
  });
786
815
  if (flags.json) return writeJson(result);
@@ -805,6 +834,7 @@ async function sdd(args) {
805
834
  changeId,
806
835
  targets: scope.targets,
807
836
  changedFiles: scope.changedFiles,
837
+ sourceBase: scope.change?.sourceBase,
808
838
  });
809
839
  const ownerStatus = getWorktreeOwnerStatus({ cwd: process.cwd() });
810
840
  if (
@@ -1005,6 +1035,10 @@ function releaseExecutionFile(changeId) {
1005
1035
  return path.join(releaseExecutionDir(changeId), 'execution.json');
1006
1036
  }
1007
1037
 
1038
+ function directPublishReceiptFile(changeId) {
1039
+ return path.join(releaseExecutionDir(changeId), 'direct-publish.json');
1040
+ }
1041
+
1008
1042
  function writePrivateJsonAtomic(file, value) {
1009
1043
  fs.mkdirSync(path.dirname(file), { recursive: true });
1010
1044
  const tempFile = `${file}.${process.pid}.${Date.now()}.tmp`;
@@ -1026,6 +1060,39 @@ function readReleaseExecution(changeId) {
1026
1060
  }
1027
1061
  }
1028
1062
 
1063
+ function readDirectPublishReceipt(changeId) {
1064
+ const file = directPublishReceiptFile(changeId);
1065
+ if (!fs.existsSync(file)) return null;
1066
+ try {
1067
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
1068
+ } catch (error) {
1069
+ fail(
1070
+ `DIRECT_PUBLISH_RECEIPT_INVALID: ${path.relative(process.cwd(), file)} 无法读取: ${error.message}`
1071
+ );
1072
+ }
1073
+ }
1074
+
1075
+ function writeDirectPublishReceipt(changeId, input = {}) {
1076
+ if (!changeId) return null;
1077
+ const receipt = {
1078
+ schemaVersion: 'openxiangda_direct_publish_receipt_v1',
1079
+ changeId,
1080
+ status: 'completed',
1081
+ command: input.command || null,
1082
+ writeAttempted: Boolean(input.writeAttempted),
1083
+ selectors: unique(input.selectors || []).sort(),
1084
+ sourceRevision: input.sourceRevision || null,
1085
+ result: input.result || null,
1086
+ completedAt: new Date().toISOString(),
1087
+ };
1088
+ const file = directPublishReceiptFile(changeId);
1089
+ writePrivateJsonAtomic(file, receipt);
1090
+ return {
1091
+ file: path.relative(process.cwd(), file).replace(/\\/g, '/'),
1092
+ receipt,
1093
+ };
1094
+ }
1095
+
1029
1096
  function summarizeReleaseStepResult(result) {
1030
1097
  const value =
1031
1098
  result && typeof result === 'object' && !Array.isArray(result)
@@ -1082,30 +1149,6 @@ function releaseExecutionContextFromBegin(result, target, changeId) {
1082
1149
  };
1083
1150
  }
1084
1151
 
1085
- function isDefinitelyPreWriteReleaseError(error) {
1086
- const code = String(error?.code || '');
1087
- if (new Set([
1088
- 'RELEASE_PUBLISH_REVISION_CHANGED',
1089
- 'RELEASE_SOURCE_DIRTY',
1090
- 'RELEASE_SOURCE_BRANCH_REQUIRED',
1091
- 'RELEASE_SOURCE_MAINLINE_REQUIRED',
1092
- 'RELEASE_SOURCE_MAINLINE_NOT_PUSHED',
1093
- 'RELEASE_SOURCE_BEHIND_MAIN',
1094
- 'RELEASE_GIT_AUTH_REQUIRED',
1095
- 'RELEASE_MAIN_BRANCH_UNRESOLVED',
1096
- 'RELEASE_MAIN_REF_UNVERIFIED',
1097
- 'SDD_PREPUBLISH_FAILED',
1098
- 'PUBLISH_CONTEXT_REQUIRED',
1099
- 'APP_RELEASE_STAGED_SCOPE_REQUIRED',
1100
- 'APP_RELEASE_STAGED_SCOPE_MISMATCH',
1101
- ]).has(code)) {
1102
- return true;
1103
- }
1104
- return /^(?:SDD_|RELEASE_(?:SOURCE|GIT|MAINLINE|MAIN_BRANCH|MAIN_REF)|RUNTIME_(?:SOURCE|BUILD|PACKAGE|DEPENDENCY))/.test(
1105
- code
1106
- );
1107
- }
1108
-
1109
1152
  function parseChildJsonOutput(stdout) {
1110
1153
  const text = String(stdout || '').trim();
1111
1154
  if (!text) return null;
@@ -1181,6 +1224,8 @@ async function waitForPublishLeaseAvailability(config, target, flags = {}) {
1181
1224
  const deadline = startedAt + waitSeconds * 1000;
1182
1225
  const clientSessionId = createPublishClientSessionId();
1183
1226
  let attempts = 0;
1227
+ let lastNoticeFingerprint = '';
1228
+ let lastNoticeAt = 0;
1184
1229
  while (true) {
1185
1230
  const remote = await fetchPublishLeaseStatus(
1186
1231
  config,
@@ -1191,10 +1236,11 @@ async function waitForPublishLeaseAvailability(config, target, flags = {}) {
1191
1236
  return { waitedMs: Date.now() - startedAt, attempts, remote };
1192
1237
  }
1193
1238
  const remoteHolder =
1194
- (typeof remote?.holder === 'string' ? remote.holder : null) ||
1195
- remote?.holder?.changeId ||
1196
1239
  remote?.changeId ||
1240
+ remote?.clientSessionId ||
1241
+ remote?.holder?.changeId ||
1197
1242
  remote?.holder?.clientSessionId ||
1243
+ (typeof remote?.holder === 'string' ? remote.holder : null) ||
1198
1244
  'another release';
1199
1245
  if (Date.now() >= deadline) {
1200
1246
  fail(
@@ -1210,9 +1256,20 @@ async function waitForPublishLeaseAvailability(config, target, flags = {}) {
1210
1256
  30000,
1211
1257
  Math.max(1000, deadline - Date.now())
1212
1258
  );
1213
- warn(
1214
- `发布队列等待中: app=${target.appType} holder=${remoteHolder} retry=${Math.ceil(delayMs / 1000)}s`
1215
- );
1259
+ const noticeFingerprint = [
1260
+ remoteHolder,
1261
+ remote?.leaseId || '',
1262
+ ].join(':');
1263
+ if (
1264
+ noticeFingerprint !== lastNoticeFingerprint ||
1265
+ Date.now() - lastNoticeAt >= 120_000
1266
+ ) {
1267
+ warn(
1268
+ `发布队列等待中: app=${target.appType} holder=${remoteHolder} retry=${Math.ceil(delayMs / 1000)}s${remote?.expiresAt ? ` expiresAt=${remote.expiresAt}` : ''}`
1269
+ );
1270
+ lastNoticeFingerprint = noticeFingerprint;
1271
+ lastNoticeAt = Date.now();
1272
+ }
1216
1273
  await sleep(delayMs);
1217
1274
  }
1218
1275
  }
@@ -1443,6 +1500,9 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1443
1500
  execution: null,
1444
1501
  };
1445
1502
  }
1503
+ const plannedSourceRevision = prepareReleaseSourceRevision({
1504
+ cwd: process.cwd(),
1505
+ });
1446
1506
  const existing = readReleaseExecution(changeId);
1447
1507
  if (
1448
1508
  existing &&
@@ -1462,6 +1522,11 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1462
1522
  'RELEASE_WRITE_REVIEW_REQUIRED: 上次写请求结果不确定;请先只读核对 staged release,确认可重试后追加 --resume-after-review'
1463
1523
  );
1464
1524
  }
1525
+ if (existing?.releaseSourceRevision) {
1526
+ assertReleaseSourceRevisionStable(existing.releaseSourceRevision, {
1527
+ cwd: process.cwd(),
1528
+ });
1529
+ }
1465
1530
  const now = new Date().toISOString();
1466
1531
  const execution = existing || {
1467
1532
  schemaVersion: 'openxiangda_release_execution_v1',
@@ -1469,6 +1534,7 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1469
1534
  profile: target.profileName,
1470
1535
  changeId,
1471
1536
  planHash,
1537
+ releaseSourceRevision: plannedSourceRevision,
1472
1538
  status: 'planned',
1473
1539
  createdAt: now,
1474
1540
  updatedAt: now,
@@ -1493,6 +1559,9 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1493
1559
  },
1494
1560
  ],
1495
1561
  };
1562
+ if (!execution.releaseSourceRevision) {
1563
+ execution.releaseSourceRevision = plannedSourceRevision;
1564
+ }
1496
1565
  if (execution.status === 'completed') {
1497
1566
  return {
1498
1567
  completed: true,
@@ -1609,11 +1678,11 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1609
1678
  execution.writeAttempted = true;
1610
1679
  }
1611
1680
  }
1612
- execution.status = execution.stagedWriteOccurred
1613
- ? 'staged-resumable'
1614
- : execution.writeAttempted
1615
- ? 'write-review-required'
1616
- : 'failed';
1681
+ execution.status = classifyReleaseExecutionFailure({
1682
+ error,
1683
+ stagedWriteOccurred: execution.stagedWriteOccurred,
1684
+ writeAttempted: execution.writeAttempted,
1685
+ });
1617
1686
  execution.updatedAt = new Date().toISOString();
1618
1687
  writePrivateJsonAtomic(releaseExecutionFile(changeId), execution);
1619
1688
  if (!execution.writeAttempted) {
@@ -1662,7 +1731,7 @@ async function release(args) {
1662
1731
  ' - begin 获取应用级单写者 promotion lease;不同工作区仍可并行开发和验证。',
1663
1732
  ' - 带 --change 的 resource/runtime 写命令在没有本地 lease 时会自动 begin,并在后续命令复用。',
1664
1733
  ' - 子 Runtime/Page/Backend/Form Release 默认先 stage;app-finalize 用 --staged-resources-json 覆盖 capture 中的 changed children,并在同一事务中原子激活。',
1665
- ' - 默认 TTL 为 1800 秒;长异步发布每 30 秒检查并在到期前自动续租,续租失败会以 PUBLISH_LEASE_LOST 阻断后续写入。',
1734
+ ' - 默认 TTL 为 120 秒;活动发布每 30 秒心跳并在到期前续租,客户端退出后孤儿租约最多阻塞约 2 分钟。',
1666
1735
  ' - begin 仅接受 clean 且与权威远端默认 main/master 完全一致的 HEAD;feature branch 先合并并 push,再由主分支一次发布。',
1667
1736
  ' - 因发布源已在远端主线,成功激活后 integration-status 会立即通过,end 不再等待补合并。',
1668
1737
  ' - integration-status --check 可作为 CI 门禁;尚未回合时返回非零。',
@@ -2080,7 +2149,14 @@ async function loadTaskStatusSnapshot(config, target, changeId) {
2080
2149
  process.cwd(),
2081
2150
  changeId
2082
2151
  );
2083
- const integration = taskResult
2152
+ const execution = readReleaseExecution(changeId);
2153
+ const directPublish = readDirectPublishReceipt(changeId);
2154
+ const sourceRevision =
2155
+ execution?.releaseSourceRevision ||
2156
+ execution?.releaseContext?.releaseSourceRevision ||
2157
+ directPublish?.sourceRevision ||
2158
+ null;
2159
+ let integration = taskResult
2084
2160
  ? inspectIntegrationBundleCommits(
2085
2161
  {
2086
2162
  requiredCommits: [
@@ -2095,13 +2171,26 @@ async function loadTaskStatusSnapshot(config, target, changeId) {
2095
2171
  { cwd: process.cwd() }
2096
2172
  )
2097
2173
  : null;
2098
- const execution = readReleaseExecution(changeId);
2174
+ let integrationError = null;
2175
+ if (!integration && sourceRevision) {
2176
+ try {
2177
+ integration = inspectReleaseSourceIntegration(sourceRevision, {
2178
+ cwd: process.cwd(),
2179
+ });
2180
+ } catch (error) {
2181
+ integrationError = {
2182
+ code: error?.code || 'RELEASE_INTEGRATION_STATUS_UNAVAILABLE',
2183
+ message: maskText(error?.message || String(error)),
2184
+ };
2185
+ }
2186
+ }
2099
2187
  const localLease = getStoredPublishLease(target);
2100
2188
  const baseline = getStoredChangeBaseline(target, {
2101
2189
  access: 'reconciliation-read',
2102
2190
  });
2103
2191
  const clientSessionId =
2104
2192
  localLease?.clientSessionId ||
2193
+ execution?.releaseContext?.clientSessionId ||
2105
2194
  baseline?.clientSessionId ||
2106
2195
  createPublishClientSessionId();
2107
2196
  let remoteLease = null;
@@ -2145,6 +2234,8 @@ async function loadTaskStatusSnapshot(config, target, changeId) {
2145
2234
  taskResult,
2146
2235
  integration,
2147
2236
  execution,
2237
+ directPublish,
2238
+ sourceRevision,
2148
2239
  remoteLease,
2149
2240
  postCommit,
2150
2241
  }),
@@ -2152,6 +2243,7 @@ async function loadTaskStatusSnapshot(config, target, changeId) {
2152
2243
  diagnostics: {
2153
2244
  remoteLeaseError,
2154
2245
  postCommitError,
2246
+ integrationError,
2155
2247
  },
2156
2248
  };
2157
2249
  }
@@ -3717,9 +3809,25 @@ function resolveChangeSourceBase(changeId, flags = {}) {
3717
3809
  if (!explicitBaseRef && changeId) {
3718
3810
  try {
3719
3811
  const scope = getSddChangeScope({ cwd: process.cwd(), changeId });
3720
- const recorded = scope?.change?.sourceBase;
3812
+ const recorded =
3813
+ scope?.change?.sourceBase ||
3814
+ scope?.release?.sourceBase;
3721
3815
  if (recorded?.baseCommit && recorded?.treeHash) return recorded;
3722
- } catch {
3816
+ const recordedBaseRevision =
3817
+ scope?.change?.baseRevision ||
3818
+ scope?.release?.baseRevision;
3819
+ if (recordedBaseRevision) {
3820
+ return readGitSourceBase(process.cwd(), recordedBaseRevision);
3821
+ }
3822
+ if (scope?.change?.changeMode === 'mainline-bundle') {
3823
+ const error = new Error(
3824
+ `SDD_SOURCE_BASE_REQUIRED: mainline bundle ${changeId} 缺少 sourceBase/baseRevision,禁止退化为当前 HEAD`
3825
+ );
3826
+ error.code = 'SDD_SOURCE_BASE_REQUIRED';
3827
+ throw error;
3828
+ }
3829
+ } catch (error) {
3830
+ if (error?.code === 'SDD_SOURCE_BASE_REQUIRED') throw error;
3723
3831
  // A direct release may use --change without a local SDD record. In that
3724
3832
  // case the current Git HEAD is still a deterministic, auditable base.
3725
3833
  }
@@ -3918,6 +4026,7 @@ async function resolvePublishLeaseForWrite(config, target, flags = {}) {
3918
4026
  const explicitLeaseId = readStringFlag(flags, 'publish-lease-id');
3919
4027
  const changeId = readStringFlag(flags, 'change');
3920
4028
  const storedLease = getStoredPublishLease(target);
4029
+ let acquiredByCommand = false;
3921
4030
  assertOrClaimWorktreeOwner({
3922
4031
  cwd: process.cwd(),
3923
4032
  changeId: changeId || storedLease?.changeId || 'unscoped-publish',
@@ -3981,6 +4090,7 @@ async function resolvePublishLeaseForWrite(config, target, flags = {}) {
3981
4090
  ...flags,
3982
4091
  'client-session-id': clientSessionId,
3983
4092
  });
4093
+ acquiredByCommand = true;
3984
4094
  } catch (error) {
3985
4095
  clearRejectedPendingBaseline(target, baseline, error);
3986
4096
  throw error;
@@ -4017,6 +4127,17 @@ async function resolvePublishLeaseForWrite(config, target, flags = {}) {
4017
4127
  if (shouldRenewPublishLease(lease)) {
4018
4128
  lease = await heartbeat.beforeWrite({ forceRenew: true });
4019
4129
  }
4130
+ if (acquiredByCommand) {
4131
+ activeCommandScopedPublishLease = {
4132
+ config,
4133
+ target,
4134
+ leaseId: lease.leaseId,
4135
+ changeId: lease.changeId || changeId,
4136
+ writeAttempted: false,
4137
+ retain: false,
4138
+ retentionReason: null,
4139
+ };
4140
+ }
4020
4141
  return lease;
4021
4142
  }
4022
4143
 
@@ -4084,6 +4205,112 @@ async function stopActivePublishLeaseHeartbeat() {
4084
4205
  if (active?.heartbeat) await active.heartbeat.stop();
4085
4206
  }
4086
4207
 
4208
+ function markCommandScopedPublishWriteAttempted() {
4209
+ if (activeCommandScopedPublishLease) {
4210
+ activeCommandScopedPublishLease.writeAttempted = true;
4211
+ }
4212
+ }
4213
+
4214
+ function retainCommandScopedPublishLease(reason = 'staged-release') {
4215
+ if (!activeCommandScopedPublishLease) return false;
4216
+ activeCommandScopedPublishLease.retain = true;
4217
+ activeCommandScopedPublishLease.retentionReason = String(reason || 'staged-release');
4218
+ return true;
4219
+ }
4220
+
4221
+ async function finalizeCommandScopedPublishLease(options = {}) {
4222
+ const active = activeCommandScopedPublishLease;
4223
+ if (!active) return null;
4224
+ activeCommandScopedPublishLease = null;
4225
+ if (active.retain) {
4226
+ return {
4227
+ managed: true,
4228
+ released: false,
4229
+ retained: true,
4230
+ reason: active.retentionReason,
4231
+ };
4232
+ }
4233
+ if (options.succeeded === false && active.writeAttempted) {
4234
+ warn(
4235
+ `PUBLISH_WRITE_REVIEW_REQUIRED: change=${active.changeId || '-'} 的写请求结果未完整确认,租约已保留供只读核对和恢复`
4236
+ );
4237
+ return {
4238
+ managed: true,
4239
+ released: false,
4240
+ retained: true,
4241
+ reason: 'write-result-review',
4242
+ };
4243
+ }
4244
+
4245
+ await stopActivePublishLeaseHeartbeat();
4246
+ const stored = getStoredPublishLease(active.target);
4247
+ if (!stored || stored.leaseId !== active.leaseId) {
4248
+ return {
4249
+ managed: true,
4250
+ released: false,
4251
+ retained: false,
4252
+ reason: 'local-lease-already-cleared',
4253
+ };
4254
+ }
4255
+ const baseline = getStoredChangeBaseline(active.target, {
4256
+ access: 'reconciliation-read',
4257
+ });
4258
+ const integration = assertReleaseSourceIntegrated(
4259
+ baseline?.releaseSourceRevision,
4260
+ { cwd: process.cwd() }
4261
+ );
4262
+ try {
4263
+ await requestWithAuth(
4264
+ active.config,
4265
+ active.target.profileName,
4266
+ publishLeaseApiPath(
4267
+ active.target,
4268
+ `/${encodeURIComponent(active.leaseId)}/release`
4269
+ ),
4270
+ {
4271
+ method: 'POST',
4272
+ body: {
4273
+ completion:
4274
+ options.succeeded === false
4275
+ ? 'prewrite-failed'
4276
+ : 'direct-publish-completed',
4277
+ sourceCommit: integration.sourceCommit,
4278
+ mainBranch: integration.mainBranch,
4279
+ mainTipCommit: integration.mainTipCommit,
4280
+ },
4281
+ }
4282
+ );
4283
+ } catch (error) {
4284
+ if (
4285
+ !['PUBLISH_LEASE_EXPIRED', 'PUBLISH_LEASE_INVALID'].includes(
4286
+ error?.code
4287
+ )
4288
+ ) {
4289
+ activeCommandScopedPublishLease = active;
4290
+ throw error;
4291
+ }
4292
+ }
4293
+ clearPublishLease(active.target, active.leaseId);
4294
+ if (baseline) {
4295
+ clearChangeBaseline(
4296
+ active.target,
4297
+ baseline.baselineId || baseline.id
4298
+ );
4299
+ }
4300
+ releaseWorktreeOwner({ cwd: process.cwd() });
4301
+ activePublishRequestContext = null;
4302
+ return {
4303
+ managed: true,
4304
+ released: true,
4305
+ retained: false,
4306
+ completion:
4307
+ options.succeeded === false
4308
+ ? 'prewrite-failed'
4309
+ : 'direct-publish-completed',
4310
+ integration,
4311
+ };
4312
+ }
4313
+
4087
4314
  function publishContextRequiredError(label, detail) {
4088
4315
  const suffix = detail ? `(${detail})` : '';
4089
4316
  const error = new Error(
@@ -5773,7 +6000,12 @@ async function workspace(args) {
5773
6000
  flags
5774
6001
  );
5775
6002
  }
5776
- runReactSpaFormSchemaPublish(profileName, profile, bound.appType, publishOptions);
6003
+ await runReactSpaFormSchemaPublish(
6004
+ profileName,
6005
+ profile,
6006
+ bound.appType,
6007
+ publishOptions
6008
+ );
5777
6009
  } else {
5778
6010
  fail(
5779
6011
  [
@@ -5794,7 +6026,12 @@ async function workspace(args) {
5794
6026
  flags
5795
6027
  );
5796
6028
  }
5797
- runWorkspacePublish(profileName, profile, bound.appType, publishOptions.workspaceArgs);
6029
+ await runWorkspacePublish(
6030
+ profileName,
6031
+ profile,
6032
+ bound.appType,
6033
+ publishOptions.workspaceArgs
6034
+ );
5798
6035
  }
5799
6036
  if (publishOptions.includeResources) {
5800
6037
  if (publishOptions.dryRun) {
@@ -5828,6 +6065,7 @@ async function workspace(args) {
5828
6065
  } else if (!publishOptions.quietResourceSkip) {
5829
6066
  print('已跳过 src/resources 发布;如需同时发布资源,传 --resources。');
5830
6067
  }
6068
+ await finalizeCommandScopedPublishLease({ succeeded: true });
5831
6069
  return;
5832
6070
  }
5833
6071
 
@@ -6644,6 +6882,12 @@ async function page(args) {
6644
6882
  },
6645
6883
  }
6646
6884
  );
6885
+ const leaseCompletion = await finalizeCommandScopedPublishLease({
6886
+ succeeded: true,
6887
+ });
6888
+ if (leaseCompletion && data && typeof data === 'object') {
6889
+ data.publishLeaseLifecycle = leaseCompletion;
6890
+ }
6647
6891
  return outputPageResult(data, flags);
6648
6892
  }
6649
6893
 
@@ -6719,6 +6963,15 @@ async function page(args) {
6719
6963
  legacyFormUuid: item.legacyFormUuid,
6720
6964
  });
6721
6965
  }
6966
+ if (!activate) {
6967
+ retainCommandScopedPublishLease('staged-page');
6968
+ }
6969
+ const leaseCompletion = await finalizeCommandScopedPublishLease({
6970
+ succeeded: true,
6971
+ });
6972
+ if (leaseCompletion && data && typeof data === 'object') {
6973
+ data.publishLeaseLifecycle = leaseCompletion;
6974
+ }
6722
6975
  return outputPageResult(data, flags);
6723
6976
  }
6724
6977
 
@@ -10244,6 +10497,18 @@ async function resource(args) {
10244
10497
  error.missingSecretRefs = missing;
10245
10498
  throw error;
10246
10499
  }
10500
+ if (publishPlan.formFieldContracts?.valid === false) {
10501
+ const violations = publishPlan.formFieldContracts.errors || [];
10502
+ const error = new Error(
10503
+ `FORM_FIELD_CONTRACT_INVALID: ${violations
10504
+ .slice(0, 8)
10505
+ .map(item => item.message)
10506
+ .join('; ')}`
10507
+ );
10508
+ error.code = 'FORM_FIELD_CONTRACT_INVALID';
10509
+ error.violations = violations;
10510
+ throw error;
10511
+ }
10247
10512
  const pruneCandidates = flags.prune
10248
10513
  ? await telemetry.runPhase('prune-plan', async () =>
10249
10514
  collectPruneResourceCandidates(config, target, manifest)
@@ -10347,6 +10612,8 @@ async function resource(args) {
10347
10612
  : [];
10348
10613
  const stagedChangeId =
10349
10614
  flags.change || getStoredChangeBaseline(target)?.changeId;
10615
+ const directPublishSourceRevision =
10616
+ getStoredChangeBaseline(target)?.releaseSourceRevision || null;
10350
10617
  if (stagedResources.length > 0 && stagedChangeId) {
10351
10618
  result.stagedResourcesFile = recordStagedAppReleaseResources(
10352
10619
  stagedChangeId,
@@ -10366,7 +10633,42 @@ async function resource(args) {
10366
10633
  target
10367
10634
  );
10368
10635
  }
10636
+ if (stagedResources.length > 0 || flags['stage-only']) {
10637
+ retainCommandScopedPublishLease('staged-resource');
10638
+ }
10639
+ const leaseCompletion = await telemetry.runPhase(
10640
+ 'lease-release',
10641
+ async () =>
10642
+ finalizeCommandScopedPublishLease({
10643
+ succeeded: true,
10644
+ })
10645
+ );
10646
+ if (leaseCompletion) {
10647
+ result.publishLeaseLifecycle = leaseCompletion;
10648
+ }
10369
10649
  result.timings = telemetry.stop();
10650
+ if (leaseCompletion?.released && stagedChangeId) {
10651
+ const receipt = writeDirectPublishReceipt(stagedChangeId, {
10652
+ command: 'resource publish',
10653
+ writeAttempted:
10654
+ (result.published || []).some(item => item.action !== 'noop') ||
10655
+ (result.deleted || []).length > 0,
10656
+ selectors: [
10657
+ ...(result.published || []).map(
10658
+ item => `${item.kind || 'resource'}:${item.code}`
10659
+ ),
10660
+ ...(result.deleted || []).map(
10661
+ item => `${item.kind || 'resource'}:${item.code}`
10662
+ ),
10663
+ ],
10664
+ sourceRevision: directPublishSourceRevision,
10665
+ result: {
10666
+ published: (result.published || []).length,
10667
+ deleted: (result.deleted || []).length,
10668
+ },
10669
+ });
10670
+ result.directPublishReceipt = receipt?.file || null;
10671
+ }
10370
10672
  if (flags.json) return writeJson(result);
10371
10673
  printResourceResult(result);
10372
10674
  } finally {
@@ -10599,6 +10901,10 @@ async function runtime(args) {
10599
10901
  target,
10600
10902
  flags
10601
10903
  );
10904
+ const directChangeId =
10905
+ flags.change || getStoredChangeBaseline(target)?.changeId;
10906
+ const directSourceRevision =
10907
+ getStoredChangeBaseline(target)?.releaseSourceRevision || null;
10602
10908
  await runRuntimeChangeBaselinePreflight(config, target);
10603
10909
  const activationPreconditions = await resolveRuntimeActivationPreconditions(
10604
10910
  config,
@@ -10623,6 +10929,18 @@ async function runtime(args) {
10623
10929
  }
10624
10930
  );
10625
10931
  saveRuntimeReleaseState(target, data);
10932
+ const leaseCompletion = await finalizeCommandScopedPublishLease({
10933
+ succeeded: true,
10934
+ });
10935
+ if (leaseCompletion?.released && directChangeId) {
10936
+ writeDirectPublishReceipt(directChangeId, {
10937
+ command: 'runtime activate',
10938
+ writeAttempted: true,
10939
+ selectors: ['runtime:active'],
10940
+ sourceRevision: directSourceRevision,
10941
+ result: { releaseId: data?.id || releaseId },
10942
+ });
10943
+ }
10626
10944
  if (flags.json) return writeJson(data);
10627
10945
  print(`runtime release 已激活: ${data.id || releaseId} build=${data.buildId || '-'}`);
10628
10946
  return;
@@ -10666,8 +10984,8 @@ async function runtime(args) {
10666
10984
  // Keep build after lease acquisition. In direct-storage modes the build
10667
10985
  // embeds the provider-specific assetBaseUrl returned by the protected
10668
10986
  // upload plan; substituting the gateway URL would change chunk/publicPath
10669
- // behavior. The 3600-second pre-build renewal below is therefore the safe
10670
- // fail-closed boundary.
10987
+ // behavior. The async build keeps the short publish-lease heartbeat alive
10988
+ // instead of extending a crash-prone one-hour lease.
10671
10989
  let publishLease = await telemetry.runPhase('lease', async () =>
10672
10990
  resolveRuntimePublishLeaseForWrite(config, target, flags)
10673
10991
  );
@@ -10748,19 +11066,8 @@ async function runtime(args) {
10748
11066
  }
10749
11067
  }
10750
11068
  if (!flags['no-build']) {
10751
- // spawnSync blocks the Node event loop, so the background heartbeat
10752
- // cannot run while the local Runtime build is executing. Extend to the
10753
- // server maximum first; if the build still outlives it, the first
10754
- // subsequent write performs a real renew and fails closed.
10755
- publishLease = await renewPublishLeaseFailClosed(
10756
- config,
10757
- target,
10758
- publishLease,
10759
- {
10760
- ...flags,
10761
- ttl: 3600,
10762
- }
10763
- );
11069
+ // Runtime builds run in an asynchronous child process so the promotion
11070
+ // heartbeat keeps renewing the short lease while compilation proceeds.
10764
11071
  await telemetry.runPhase('build', async () =>
10765
11072
  runRuntimeBuild({
10766
11073
  buildId,
@@ -10952,8 +11259,35 @@ async function runtime(args) {
10952
11259
  traceId,
10953
11260
  ...(stagedResource ? { stagedResource } : {}),
10954
11261
  ...(stagedResourcesFile ? { stagedResourcesFile } : {}),
10955
- timings: telemetry.stop(),
10956
11262
  };
11263
+ if (stagedResource) {
11264
+ retainCommandScopedPublishLease('staged-runtime');
11265
+ }
11266
+ const leaseCompletion = await telemetry.runPhase(
11267
+ 'lease-release',
11268
+ async () =>
11269
+ finalizeCommandScopedPublishLease({
11270
+ succeeded: true,
11271
+ })
11272
+ );
11273
+ if (leaseCompletion) {
11274
+ result.publishLeaseLifecycle = leaseCompletion;
11275
+ }
11276
+ result.timings = telemetry.stop();
11277
+ if (leaseCompletion?.released && stagedChangeId) {
11278
+ const receipt = writeDirectPublishReceipt(stagedChangeId, {
11279
+ command: 'runtime deploy',
11280
+ writeAttempted: true,
11281
+ selectors: ['runtime:active'],
11282
+ sourceRevision: runtimeLineage.sourceRevision,
11283
+ result: {
11284
+ releaseId: data?.id || null,
11285
+ buildId,
11286
+ activated: true,
11287
+ },
11288
+ });
11289
+ result.directPublishReceipt = receipt?.file || null;
11290
+ }
10957
11291
  if (flags.json) return writeJson(result);
10958
11292
  print(
10959
11293
  [
@@ -11056,32 +11390,61 @@ async function createRuntimeOssUploadBasePlan(options) {
11056
11390
  );
11057
11391
  }
11058
11392
 
11059
- function runRuntimeBuild(options) {
11393
+ async function runRuntimeBuild(options) {
11060
11394
  const command = options.command || defaultRuntimeBuildCommand();
11061
11395
  if (options.jsonOutput) {
11062
11396
  printRuntimeProgress(`构建 React SPA runtime: ${command}`);
11063
11397
  } else {
11064
11398
  print(`构建 React SPA runtime: ${command}`);
11065
11399
  }
11066
- const result = spawnSync(command, [], {
11067
- cwd: process.cwd(),
11068
- shell: true,
11069
- stdio: options.jsonOutput ? 'pipe' : 'inherit',
11070
- encoding: options.jsonOutput ? 'utf8' : undefined,
11071
- env: {
11072
- ...process.env,
11073
- OPENXIANGDA_APP_TYPE: options.appType,
11074
- APP_TYPE: options.appType,
11075
- OPENXIANGDA_BUILD_ID: options.buildId,
11076
- OPENXIANGDA_RUNTIME_ASSET_BASE: options.assetBaseUrl,
11077
- },
11400
+ await new Promise((resolve, reject) => {
11401
+ let child;
11402
+ try {
11403
+ child = spawn(command, [], {
11404
+ cwd: process.cwd(),
11405
+ shell: true,
11406
+ stdio: options.jsonOutput
11407
+ ? ['ignore', 'pipe', 'pipe']
11408
+ : 'inherit',
11409
+ env: {
11410
+ ...process.env,
11411
+ OPENXIANGDA_APP_TYPE: options.appType,
11412
+ APP_TYPE: options.appType,
11413
+ OPENXIANGDA_BUILD_ID: options.buildId,
11414
+ OPENXIANGDA_RUNTIME_ASSET_BASE: options.assetBaseUrl,
11415
+ },
11416
+ });
11417
+ } catch (error) {
11418
+ reject(
11419
+ new Error(`runtime build 无法启动: ${error?.message || String(error)}`)
11420
+ );
11421
+ return;
11422
+ }
11423
+ if (options.jsonOutput) {
11424
+ child.stdout?.on('data', chunk => {
11425
+ process.stderr.write(maskText(String(chunk)));
11426
+ });
11427
+ child.stderr?.on('data', chunk => {
11428
+ process.stderr.write(maskText(String(chunk)));
11429
+ });
11430
+ }
11431
+ child.once('error', error => {
11432
+ reject(new Error(`runtime build 无法启动: ${error.message}`));
11433
+ });
11434
+ child.once('close', (code, signal) => {
11435
+ if (code === 0) {
11436
+ resolve();
11437
+ return;
11438
+ }
11439
+ reject(
11440
+ new Error(
11441
+ `runtime build 失败: ${
11442
+ Number.isInteger(code) ? `exit ${code}` : `signal ${signal || '-'}`
11443
+ }`
11444
+ )
11445
+ );
11446
+ });
11078
11447
  });
11079
- if (options.jsonOutput) {
11080
- if (result.stdout) process.stderr.write(maskText(result.stdout));
11081
- if (result.stderr) process.stderr.write(maskText(result.stderr));
11082
- }
11083
- if (result.error) fail(`runtime build 无法启动: ${result.error.message}`);
11084
- if (result.status !== 0) fail(`runtime build 失败: exit ${result.status}`);
11085
11448
  }
11086
11449
 
11087
11450
  function assertRuntimeBuildReady(explicitCommand) {
@@ -14705,6 +15068,18 @@ async function publishResourcesForWorkspace(config, profileName, options = {}) {
14705
15068
  }
14706
15069
  const target = getWorkspaceTarget(config, profileName, {});
14707
15070
  const plan = await buildResourcePlan(config, target, manifest);
15071
+ if (plan.formFieldContracts?.valid === false) {
15072
+ const violations = plan.formFieldContracts.errors || [];
15073
+ const error = new Error(
15074
+ `FORM_FIELD_CONTRACT_INVALID: ${violations
15075
+ .slice(0, 8)
15076
+ .map(item => item.message)
15077
+ .join('; ')}`
15078
+ );
15079
+ error.code = 'FORM_FIELD_CONTRACT_INVALID';
15080
+ error.violations = violations;
15081
+ throw error;
15082
+ }
14708
15083
  const pruneCandidates = options.prune
14709
15084
  ? await collectPruneResourceCandidates(config, target, manifest)
14710
15085
  : [];
@@ -14740,7 +15115,10 @@ async function buildResourcePlan(config, target, manifest) {
14740
15115
  manifest
14741
15116
  );
14742
15117
  await prepareManifestJsCodeBundlesForPlan(manifest);
14743
- const existing = await fetchExistingResourceMaps(config, target, manifest);
15118
+ const [existing, formFieldContracts] = await Promise.all([
15119
+ fetchExistingResourceMaps(config, target, manifest),
15120
+ buildFormFieldContractPlan(config, target, manifest),
15121
+ ]);
14744
15122
  const actions = [];
14745
15123
  addPlanActions(actions, 'role', manifest.roles, existing.roles, roleEquals);
14746
15124
  addPlanActions(actions, 'menu', manifest.menus, existing.menus, (item, current) => menuEquals(target.bound, item, current));
@@ -14780,6 +15158,7 @@ async function buildResourcePlan(config, target, manifest) {
14780
15158
  ...(manifest.resourceCodeFilters ? { resourceCodeFilters: manifest.resourceCodeFilters } : {}),
14781
15159
  actions,
14782
15160
  summary: summarizeActions(actions),
15161
+ formFieldContracts,
14783
15162
  ...(functionSecretRefs
14784
15163
  ? {
14785
15164
  capabilities: {
@@ -16170,7 +16549,14 @@ async function publishResourceManifest(config, target, manifest, options = {}) {
16170
16549
  }
16171
16550
 
16172
16551
  await publishRoleResources(config, target, manifest.roles || [], result, publishOptions);
16173
- await publishFormSettingsResources(config, target, manifest.formSettings || [], result, publishOptions);
16552
+ await publishFormSettingsResources(
16553
+ config,
16554
+ target,
16555
+ manifest.formSettings || [],
16556
+ manifest.formPermissionGroups || [],
16557
+ result,
16558
+ publishOptions
16559
+ );
16174
16560
  await publishMenuResources(config, target, manifest.menus || [], result, publishOptions);
16175
16561
  await publishConnectorResources(config, target, manifest.connectors || [], result, publishOptions);
16176
16562
  await publishNotificationResources(config, target, manifest.notifications || [], result, publishOptions);
@@ -16200,7 +16586,13 @@ async function publishResourceManifest(config, target, manifest, options = {}) {
16200
16586
  await publishStorageConfigResources(config, target, manifest.storageConfigs || [], result, publishOptions);
16201
16587
  await publishPublicAccessPolicyResources(config, target, manifest.publicAccessPolicies || [], result, publishOptions);
16202
16588
  await publishPagePermissionGroupResources(config, target, manifest.pagePermissionGroups || [], result, publishOptions);
16203
- await publishFormPermissionGroupResources(config, target, manifest.formPermissionGroups || [], result, publishOptions);
16589
+ await publishFormPermissionGroupResources(
16590
+ config,
16591
+ target,
16592
+ manifest.formPermissionGroups || [],
16593
+ result,
16594
+ publishOptions
16595
+ );
16204
16596
  if (options.prune) {
16205
16597
  await pruneResourceManifest(
16206
16598
  config,
@@ -17591,22 +17983,82 @@ async function publishFormSettingsResources(
17591
17983
  config,
17592
17984
  target,
17593
17985
  settingsItems,
17986
+ permissionGroups,
17594
17987
  result,
17595
17988
  options = {}
17596
17989
  ) {
17990
+ const settingsByForm = new Map();
17597
17991
  for (const item of settingsItems) {
17598
17992
  const code = item.code || item.formCode || item.formUuid;
17599
- if (shouldSkipNoopResource(options, 'formSetting', code)) {
17600
- recordNoopResource(result, 'formSetting', code, options);
17993
+ const formUuid = resolveManifestFormUuid(target.bound, item, {
17994
+ fallbackToCode: true,
17995
+ });
17996
+ if (!formUuid) fail(`表单设置 ${code} 无法解析 formUuid`);
17997
+ settingsByForm.set(formUuid, { item, code });
17998
+ }
17999
+ const changedGroupsByForm = new Map();
18000
+ options.formReleaseHandledPermissionCodes =
18001
+ options.formReleaseHandledPermissionCodes || new Set();
18002
+ for (const group of permissionGroups || []) {
18003
+ const code = group.code || group.resourceCode;
18004
+ if (shouldSkipNoopResource(options, 'formPermissionGroup', code)) {
18005
+ recordNoopResource(result, 'formPermissionGroup', code, options);
18006
+ options.formReleaseHandledPermissionCodes.add(code);
17601
18007
  continue;
17602
18008
  }
17603
- const formUuid = resolveManifestFormUuid(target.bound, item, { fallbackToCode: true });
17604
- if (!formUuid) fail(`表单设置 ${code} 无法解析 formUuid`);
17605
- const action = getPlannedResourceAction(options, 'formSetting', code);
18009
+ const formUuid = resolveManifestFormUuid(target.bound, group);
18010
+ if (!formUuid) {
18011
+ fail(`表单权限组 ${code} 无法解析 formUuid,不能进入 FormRelease`);
18012
+ }
18013
+ if (!changedGroupsByForm.has(formUuid)) {
18014
+ changedGroupsByForm.set(formUuid, []);
18015
+ }
18016
+ changedGroupsByForm.get(formUuid).push(group);
18017
+ }
18018
+ const formUuids = unique([
18019
+ ...settingsByForm.keys(),
18020
+ ...changedGroupsByForm.keys(),
18021
+ ]).sort();
18022
+ for (const formUuid of formUuids) {
18023
+ const setting = settingsByForm.get(formUuid);
18024
+ const item = setting?.item || null;
18025
+ const groups = changedGroupsByForm.get(formUuid) || [];
18026
+ const code =
18027
+ setting?.code ||
18028
+ groups[0]?.formCode ||
18029
+ groups[0]?.form ||
18030
+ formUuid;
18031
+ const settingNoop =
18032
+ item && shouldSkipNoopResource(options, 'formSetting', code);
18033
+ if (settingNoop) {
18034
+ recordNoopResource(result, 'formSetting', code, options);
18035
+ }
18036
+ if (settingNoop && groups.length === 0) continue;
18037
+ const action = item
18038
+ ? getPlannedResourceAction(options, 'formSetting', code)
18039
+ : null;
17606
18040
  const frozen =
17607
18041
  frozenFormConfigFromPlanAction(action) ||
17608
18042
  (await fetchFrozenFormConfig(config, target, formUuid));
17609
- const bundleBody = buildFormSettingBundleBody(item);
18043
+ const bundleBody =
18044
+ item && !settingNoop ? buildFormSettingBundleBody(item) : {};
18045
+ if (groups.length > 0) {
18046
+ bundleBody.permissionGroups = groups.map(group => {
18047
+ const body = {
18048
+ ...withoutResourceMeta(group),
18049
+ resourceCode: group.code || group.resourceCode,
18050
+ name: group.name || group.code || group.resourceCode,
18051
+ type: group.type || 'view',
18052
+ roles: group.roles || [],
18053
+ };
18054
+ delete body.code;
18055
+ delete body.formCode;
18056
+ delete body.form;
18057
+ delete body.formUuid;
18058
+ return body;
18059
+ });
18060
+ bundleBody.permissionGroupsMode = 'merge';
18061
+ }
17610
18062
  const activate = options.activateFormBundles === true;
17611
18063
  const artifactId = stableFormArtifactId(
17612
18064
  'config-bundle',
@@ -17627,7 +18079,19 @@ async function publishFormSettingsResources(
17627
18079
  },
17628
18080
  { frozen, artifactId }
17629
18081
  );
17630
- saveResourceEntry(target, 'formSettings', code, { formUuid });
18082
+ if (item) {
18083
+ saveResourceEntry(target, 'formSettings', code, { formUuid });
18084
+ }
18085
+ for (const group of groups) {
18086
+ const groupCode = group.code || group.resourceCode;
18087
+ saveFormPermissionGroupResource(
18088
+ target,
18089
+ groupCode,
18090
+ undefined,
18091
+ { formUuid }
18092
+ );
18093
+ options.formReleaseHandledPermissionCodes.add(groupCode);
18094
+ }
17631
18095
  const releaseId = data?.release?.id;
17632
18096
  const contentHash = String(
17633
18097
  data?.release?.contentHash || data?.contentHash || ''
@@ -17676,20 +18140,36 @@ async function publishFormSettingsResources(
17676
18140
  delete result.stagedResource;
17677
18141
  }
17678
18142
  }
17679
- result.published.push({
17680
- kind: 'formSetting',
17681
- code,
17682
- action: data?.noop ? 'noop' : data?.active ? 'update' : 'stage',
17683
- formUuid,
17684
- revision: data?.revision,
17685
- activeFormReleaseHead: data?.activeFormReleaseHead,
17686
- staged: Boolean(data?.staged),
17687
- active: Boolean(data?.active),
17688
- releaseStatus: data?.releaseStatus,
17689
- releaseId,
17690
- contentHash: contentHash || undefined,
17691
- ...(stagedResource ? { stagedResource } : {}),
17692
- });
18143
+ if (item && !settingNoop) {
18144
+ result.published.push({
18145
+ kind: 'formSetting',
18146
+ code,
18147
+ action: data?.noop ? 'noop' : data?.active ? 'update' : 'stage',
18148
+ formUuid,
18149
+ revision: data?.revision,
18150
+ activeFormReleaseHead: data?.activeFormReleaseHead,
18151
+ staged: Boolean(data?.staged),
18152
+ active: Boolean(data?.active),
18153
+ releaseStatus: data?.releaseStatus,
18154
+ releaseId,
18155
+ contentHash: contentHash || undefined,
18156
+ ...(stagedResource ? { stagedResource } : {}),
18157
+ });
18158
+ }
18159
+ for (const group of groups) {
18160
+ result.published.push({
18161
+ kind: 'formPermissionGroup',
18162
+ code: group.code || group.resourceCode,
18163
+ action: data?.noop ? 'noop' : data?.active ? 'update' : 'stage',
18164
+ formUuid,
18165
+ staged: Boolean(data?.staged),
18166
+ active: Boolean(data?.active),
18167
+ releaseStatus: data?.releaseStatus,
18168
+ releaseId,
18169
+ contentHash: contentHash || undefined,
18170
+ ...(stagedResource ? { stagedResource } : {}),
18171
+ });
18172
+ }
17693
18173
  }
17694
18174
  }
17695
18175
 
@@ -18810,38 +19290,24 @@ async function findExistingPagePermissionGroup(config, target, code) {
18810
19290
 
18811
19291
  async function publishFormPermissionGroupResources(config, target, groups, result, options = {}) {
18812
19292
  for (const group of groups) {
19293
+ if (
19294
+ options.formReleaseHandledPermissionCodes?.has(
19295
+ group.code || group.resourceCode
19296
+ )
19297
+ ) {
19298
+ continue;
19299
+ }
18813
19300
  if (shouldSkipNoopResource(options, 'formPermissionGroup', group.code)) {
18814
19301
  recordNoopResource(result, 'formPermissionGroup', group.code, options);
18815
19302
  continue;
18816
19303
  }
18817
- const formUuid = resolveManifestFormUuid(target.bound, group);
18818
- const existing = await findExistingFormPermissionGroup(config, target, group.code, formUuid);
18819
- const body = {
18820
- ...withoutResourceMeta(group),
18821
- resourceCode: group.code,
18822
- formUuid,
18823
- name: group.name || group.code,
18824
- type: group.type || 'view',
18825
- roles: group.roles || [],
18826
- };
18827
- delete body.code;
18828
- delete body.formCode;
18829
- delete body.form;
18830
- const data = existing
18831
- ? await requestWithAuth(
18832
- config,
18833
- target.profileName,
18834
- `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/forms/${encodeURIComponent(formUuid)}/permission-groups/${encodeURIComponent(existing.id)}`,
18835
- { method: 'POST', body }
18836
- )
18837
- : await requestWithAuth(
18838
- config,
18839
- target.profileName,
18840
- `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/forms/${encodeURIComponent(formUuid)}/permission-groups`,
18841
- { method: 'POST', body }
18842
- );
18843
- if (data?.id) saveFormPermissionGroupResource(target, group.code, data.id, { formUuid, name: data.name, resourceCode: group.code });
18844
- result.published.push({ kind: 'formPermissionGroup', code: group.code, action: existing ? 'update' : 'create', id: data?.id });
19304
+ const error = new Error(
19305
+ `FORM_PERMISSION_GROUP_STAGE_REQUIRED: ${
19306
+ group.code || group.resourceCode
19307
+ } 未进入 FormRelease,禁止回退为直接线上写入`
19308
+ );
19309
+ error.code = 'FORM_PERMISSION_GROUP_STAGE_REQUIRED';
19310
+ throw error;
18845
19311
  }
18846
19312
  }
18847
19313
 
@@ -23461,6 +23927,32 @@ function publishContextHeadersForRequest(config, profileName, apiPath, options =
23461
23927
  );
23462
23928
  }
23463
23929
 
23930
+ function isPublishMutationRequest(apiPath, options = {}) {
23931
+ const method = String(options.method || 'GET').toUpperCase();
23932
+ if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return false;
23933
+ const normalizedPath = String(apiPath || '').split('?')[0];
23934
+ if (
23935
+ normalizedPath.includes('/publish-lease/') ||
23936
+ normalizedPath.endsWith('/publish-lease/acquire') ||
23937
+ normalizedPath.includes('/change-baselines')
23938
+ ) {
23939
+ return false;
23940
+ }
23941
+ if (
23942
+ normalizedPath.endsWith('/oss-upload-plan') &&
23943
+ options.body?.planOnly === true
23944
+ ) {
23945
+ return false;
23946
+ }
23947
+ if (
23948
+ normalizedPath.endsWith('/schema-storage-plan') ||
23949
+ normalizedPath.endsWith('/storage-plan')
23950
+ ) {
23951
+ return false;
23952
+ }
23953
+ return true;
23954
+ }
23955
+
23464
23956
  async function requestWithAuth(config, profileName, apiPath, options = {}) {
23465
23957
  assertReadOnlyHttpRequest(apiPath, options);
23466
23958
  const resolved = getProfile(config, profileName);
@@ -23482,6 +23974,9 @@ async function requestWithAuth(config, profileName, apiPath, options = {}) {
23482
23974
  ) {
23483
23975
  await activePublishLeaseHeartbeat?.heartbeat?.beforeWrite();
23484
23976
  }
23977
+ if (isPublishMutationRequest(apiPath, requestOptions)) {
23978
+ markCommandScopedPublishWriteAttempted();
23979
+ }
23485
23980
  // Build headers after the lease check: a successful renew may replace the
23486
23981
  // live client session/expiry metadata, and auth/transient retries must not
23487
23982
  // replay stale publish context.
@@ -23542,6 +24037,7 @@ async function requestFormWithAuth(config, profileName, apiPath, formDataFactory
23542
24037
  if (!skipPublishLeaseHeartbeat) {
23543
24038
  await activePublishLeaseHeartbeat?.heartbeat?.beforeWrite();
23544
24039
  }
24040
+ markCommandScopedPublishWriteAttempted();
23545
24041
  const currentRequestOptions = {
23546
24042
  ...requestOptions,
23547
24043
  headers: {
@@ -23759,7 +24255,7 @@ function normalizeWorkspacePublishOptions(flags) {
23759
24255
  };
23760
24256
  }
23761
24257
 
23762
- function runWorkspacePublish(profileName, profile, appType, publishArgs = []) {
24258
+ async function runWorkspacePublish(profileName, profile, appType, publishArgs = []) {
23763
24259
  const packageFile = path.join(process.cwd(), 'package.json');
23764
24260
  if (!fs.existsSync(packageFile)) {
23765
24261
  fail('当前目录没有 package.json,无法识别工作区发布脚本');
@@ -23781,14 +24277,13 @@ function runWorkspacePublish(profileName, profile, appType, publishArgs = []) {
23781
24277
  ? ['run', scriptName, '--', ...publishArgs]
23782
24278
  : ['run', scriptName];
23783
24279
  const globalEnv = loadGlobalEnv();
23784
- const result = spawnSync(command, args, {
24280
+ await runWorkspaceChildCommand(command, args, {
23785
24281
  cwd: process.cwd(),
23786
- stdio: 'inherit',
23787
24282
  env: buildWorkspacePublishEnv(profileName, profile, appType, globalEnv),
24283
+ failureCode: 'WORKSPACE_PUBLISH_FAILED',
24284
+ label: scriptName,
24285
+ marksPublishWrite: true,
23788
24286
  });
23789
- if (result.status !== 0) {
23790
- process.exit(result.status || 1);
23791
- }
23792
24287
  }
23793
24288
 
23794
24289
  function isReactSpaWorkspace() {
@@ -23798,7 +24293,12 @@ function isReactSpaWorkspace() {
23798
24293
  return /runtimeMode\s*:\s*['"]react-spa['"]/.test(content);
23799
24294
  }
23800
24295
 
23801
- function runReactSpaFormSchemaPublish(profileName, profile, appType, publishOptions) {
24296
+ async function runReactSpaFormSchemaPublish(
24297
+ profileName,
24298
+ profile,
24299
+ appType,
24300
+ publishOptions
24301
+ ) {
23802
24302
  const packageFile = path.join(process.cwd(), 'package.json');
23803
24303
  if (!fs.existsSync(packageFile)) {
23804
24304
  fail('当前目录没有 package.json,无法识别工作区发布脚本');
@@ -23818,14 +24318,137 @@ function runReactSpaFormSchemaPublish(profileName, profile, appType, publishOpti
23818
24318
  `React SPA 表单 schema 发布${publishOptions.dryRun ? ' (dry-run)' : ''}: ${publishOptions.targetForm}`
23819
24319
  );
23820
24320
  const globalEnv = loadGlobalEnv();
23821
- const result = spawnSync(command, args, {
24321
+ await runWorkspaceChildCommand(command, args, {
23822
24322
  cwd: process.cwd(),
23823
- stdio: 'inherit',
23824
24323
  env: buildWorkspacePublishEnv(profileName, profile, appType, globalEnv),
24324
+ failureCode: 'FORM_SCHEMA_PUBLISH_FAILED',
24325
+ label: `form schema ${publishOptions.targetForm}`,
24326
+ marksPublishWrite: true,
23825
24327
  });
23826
- if (result.status !== 0) {
23827
- process.exit(result.status || 1);
24328
+ }
24329
+
24330
+ async function runWorkspaceChildCommand(command, args, options = {}) {
24331
+ await new Promise((resolve, reject) => {
24332
+ const child = spawn(command, args, {
24333
+ cwd: options.cwd || process.cwd(),
24334
+ stdio: 'inherit',
24335
+ env: options.env || process.env,
24336
+ });
24337
+ child.once('spawn', () => {
24338
+ if (options.marksPublishWrite) {
24339
+ markCommandScopedPublishWriteAttempted();
24340
+ }
24341
+ });
24342
+ child.once('error', reject);
24343
+ child.once('close', (code, signal) => {
24344
+ if (code === 0) {
24345
+ resolve();
24346
+ return;
24347
+ }
24348
+ const error = new Error(
24349
+ `${options.label || command} 执行失败${signal ? `(signal=${signal})` : `(exit=${code ?? 1})`}`
24350
+ );
24351
+ error.code = options.failureCode || 'WORKSPACE_CHILD_COMMAND_FAILED';
24352
+ error.exitCode = Number.isInteger(code) ? code : 1;
24353
+ reject(error);
24354
+ });
24355
+ });
24356
+ }
24357
+
24358
+ async function buildFormFieldContractPlan(config, target, manifest) {
24359
+ const targets = [];
24360
+ for (const [manifestKey, kind] of [
24361
+ ['functions', 'Function'],
24362
+ ['automations', 'Automation'],
24363
+ ['workflows', 'Workflow'],
24364
+ ['jsCodeNodes', 'JsCodeNode'],
24365
+ ]) {
24366
+ for (const item of manifest[manifestKey] || []) {
24367
+ const analysis = item.__sourceAnalysis;
24368
+ if (!analysis?.formFieldReferences?.length) continue;
24369
+ const code =
24370
+ item.code ||
24371
+ item.functionCode ||
24372
+ item.resourceCode ||
24373
+ item.scriptCode;
24374
+ for (const reference of analysis.formFieldReferences) {
24375
+ const formUuid = resolveManifestFormBindingForContract(
24376
+ target,
24377
+ item,
24378
+ reference.formCode
24379
+ );
24380
+ targets.push({
24381
+ kind,
24382
+ code,
24383
+ formCode: reference.formCode,
24384
+ formUuid,
24385
+ field: reference.field,
24386
+ api: reference.api,
24387
+ file: reference.file,
24388
+ line: reference.line,
24389
+ });
24390
+ }
24391
+ }
24392
+ }
24393
+ if (targets.length === 0) {
24394
+ return {
24395
+ contractVersion: 'form_field_contract_v1',
24396
+ valid: true,
24397
+ checkedForms: 0,
24398
+ checkedReferences: 0,
24399
+ errors: [],
24400
+ };
23828
24401
  }
24402
+ const snapshots = new Map();
24403
+ const formTargets = unique(
24404
+ targets.map(item => item.formUuid).filter(Boolean)
24405
+ ).sort();
24406
+ await Promise.all(
24407
+ formTargets.map(async formUuid => {
24408
+ const frozen = await fetchFrozenFormConfig(
24409
+ config,
24410
+ target,
24411
+ formUuid
24412
+ );
24413
+ snapshots.set(formUuid, collectFormSnapshotFieldIds(frozen.snapshot));
24414
+ })
24415
+ );
24416
+ return validateFormFieldContracts(targets, snapshots);
24417
+ }
24418
+
24419
+ function resolveManifestFormBindingForContract(target, item, formCode) {
24420
+ const containers = [
24421
+ item.resourceBindings,
24422
+ item.resources,
24423
+ item.definitionJson?.resourceBindings,
24424
+ item.definitionJson?.resources,
24425
+ ];
24426
+ if (item.definitionFile) {
24427
+ try {
24428
+ const definition = JSON.parse(
24429
+ fs.readFileSync(
24430
+ path.resolve(item.__dir || process.cwd(), item.definitionFile),
24431
+ 'utf8'
24432
+ )
24433
+ );
24434
+ containers.push(definition.resourceBindings, definition.resources);
24435
+ } catch {
24436
+ // Resource validation reports invalid definition files separately.
24437
+ }
24438
+ }
24439
+ for (const container of containers) {
24440
+ const binding = container?.forms?.[formCode];
24441
+ if (typeof binding === 'string' && binding.trim()) return binding.trim();
24442
+ const formUuid = String(
24443
+ binding?.formUuid || binding?.id || binding?.uuid || ''
24444
+ ).trim();
24445
+ if (formUuid) return formUuid;
24446
+ }
24447
+ return resolveManifestFormUuid(
24448
+ target.bound,
24449
+ { formCode },
24450
+ { fallbackToCode: false }
24451
+ );
23829
24452
  }
23830
24453
 
23831
24454
  function buildWorkspacePublishEnv(profileName, profile, appType, globalEnv) {