openxiangda 1.0.176 → 1.0.177

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/README.md CHANGED
@@ -123,13 +123,15 @@ Domestic npm mirrors may lag and return an older OpenXiangda version. `openxiang
123
123
 
124
124
  ## AI design gate and resource CLI
125
125
 
126
- Architecture-class requests are plan-gated by default. For new apps, complex pages, login/register, public/no-login access, role/data-scope design, workflow/automation, App Function, connector, notification, and external integration work, AI agents must plan first and implement only after the user confirms the design.
126
+ Architecture-class requests run the relevant design gate first. AI agents pause only for unresolved business, security, or data choices that would change the implementation. When the user already supplied concrete requirements and acceptance criteria, agents record the structured SDD scope and implement without a long design essay or duplicate confirmation.
127
127
 
128
128
  Risk is tiered. Read-only/docs/tests are L0. Narrow reversible fixes are L1 and use `sdd quick`; L2/L3 retain explicit approval and exact structured coverage. SDD is structured-first by default: `change.json`, `coverage.json`, and `release.json` are authoritative, while proposal/design/tasks/evidence/spec prose is generated only by `openxiangda sdd render <change>` or `documentationMode: 'full'`. Workspaces that intentionally require prose completion may set `strictDocumentation: true`. Actual argv, exact scope, mainline identity, lease, CAS, destructive operations, Secrets, and Root App atomic activation remain hard gates.
129
129
 
130
130
  L1 quick records are generated in a compact form and should not be expanded into design essays. Mainline bundles may keep `<profile>` in reviewed command templates; `release publish --profile <name>` binds the real profile to actual argv without rewriting tracked SDD. React SPA page codes remain logical coverage and activate through one Runtime child. `release app-head` and `runtime releases` return compact summaries by default; add `--full` for the complete manifest.
131
131
 
132
- Every concurrent task uses its own Git worktree/branch and development change. The mainline release coordinator bundles selected approved changes after merge; dependency impact outside the approved scope is reported as a warning rather than silently widening a small release. Live commands still require canonical exact selectors, and app-wide/delete operations need explicit authority.
132
+ Every concurrent task uses its own Git worktree/branch and development change. The canonical main checkout is reserved for integration and release; tasks never stash/restore each other's files to make it publishable. The mainline release coordinator bundles selected approved changes after merge; dependency impact outside the approved scope is reported as a warning rather than silently widening a small release. Live commands still require canonical exact selectors, and app-wide/delete operations need explicit authority.
133
+
134
+ Agent investigation is bounded to one complete CodeGraph survey plus at most one focused follow-up, and ordinary work loads one domain skill. Unchanged lease/build waits use `task status --watch` and produce updates only on material transitions.
133
135
 
134
136
  Before confirmation, agents may read, inspect, snapshot, dry-run, ask questions, and output/write the architecture document. They must not edit source files, mutate platform resources, publish, deploy, send notifications, or call live write/delete endpoints.
135
137
 
package/lib/cli.js CHANGED
@@ -170,13 +170,32 @@ let readOnlyHttpGuardDepth = 0;
170
170
  let readOnlyHttpGuardLabel = null;
171
171
  let activePublishRequestContext = null;
172
172
  let activePublishLeaseHeartbeat = null;
173
+ let activeCommandScopedPublishLease = null;
173
174
  const genericGitBasePlanCache = new Map();
174
175
 
175
176
  async function main(argv) {
177
+ let commandError = null;
176
178
  try {
177
179
  return await mainImpl(argv);
180
+ } catch (error) {
181
+ commandError = error;
182
+ throw error;
178
183
  } finally {
179
184
  await stopActivePublishLeaseHeartbeat();
185
+ try {
186
+ await finalizeCommandScopedPublishLease({
187
+ succeeded: commandError === null,
188
+ });
189
+ } catch (cleanupError) {
190
+ if (!commandError) throw cleanupError;
191
+ commandError.publishLeaseCleanupError = {
192
+ code: cleanupError?.code || 'PUBLISH_LEASE_CLEANUP_FAILED',
193
+ message: maskText(cleanupError?.message || String(cleanupError)),
194
+ };
195
+ warn(
196
+ `发布命令失败后的租约清理也未完成:${commandError.publishLeaseCleanupError.message}`
197
+ );
198
+ }
180
199
  activePublishRequestContext = null;
181
200
  }
182
201
  }
@@ -1005,6 +1024,10 @@ function releaseExecutionFile(changeId) {
1005
1024
  return path.join(releaseExecutionDir(changeId), 'execution.json');
1006
1025
  }
1007
1026
 
1027
+ function directPublishReceiptFile(changeId) {
1028
+ return path.join(releaseExecutionDir(changeId), 'direct-publish.json');
1029
+ }
1030
+
1008
1031
  function writePrivateJsonAtomic(file, value) {
1009
1032
  fs.mkdirSync(path.dirname(file), { recursive: true });
1010
1033
  const tempFile = `${file}.${process.pid}.${Date.now()}.tmp`;
@@ -1026,6 +1049,39 @@ function readReleaseExecution(changeId) {
1026
1049
  }
1027
1050
  }
1028
1051
 
1052
+ function readDirectPublishReceipt(changeId) {
1053
+ const file = directPublishReceiptFile(changeId);
1054
+ if (!fs.existsSync(file)) return null;
1055
+ try {
1056
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
1057
+ } catch (error) {
1058
+ fail(
1059
+ `DIRECT_PUBLISH_RECEIPT_INVALID: ${path.relative(process.cwd(), file)} 无法读取: ${error.message}`
1060
+ );
1061
+ }
1062
+ }
1063
+
1064
+ function writeDirectPublishReceipt(changeId, input = {}) {
1065
+ if (!changeId) return null;
1066
+ const receipt = {
1067
+ schemaVersion: 'openxiangda_direct_publish_receipt_v1',
1068
+ changeId,
1069
+ status: 'completed',
1070
+ command: input.command || null,
1071
+ writeAttempted: Boolean(input.writeAttempted),
1072
+ selectors: unique(input.selectors || []).sort(),
1073
+ sourceRevision: input.sourceRevision || null,
1074
+ result: input.result || null,
1075
+ completedAt: new Date().toISOString(),
1076
+ };
1077
+ const file = directPublishReceiptFile(changeId);
1078
+ writePrivateJsonAtomic(file, receipt);
1079
+ return {
1080
+ file: path.relative(process.cwd(), file).replace(/\\/g, '/'),
1081
+ receipt,
1082
+ };
1083
+ }
1084
+
1029
1085
  function summarizeReleaseStepResult(result) {
1030
1086
  const value =
1031
1087
  result && typeof result === 'object' && !Array.isArray(result)
@@ -1181,6 +1237,8 @@ async function waitForPublishLeaseAvailability(config, target, flags = {}) {
1181
1237
  const deadline = startedAt + waitSeconds * 1000;
1182
1238
  const clientSessionId = createPublishClientSessionId();
1183
1239
  let attempts = 0;
1240
+ let lastNoticeFingerprint = '';
1241
+ let lastNoticeAt = 0;
1184
1242
  while (true) {
1185
1243
  const remote = await fetchPublishLeaseStatus(
1186
1244
  config,
@@ -1191,10 +1249,11 @@ async function waitForPublishLeaseAvailability(config, target, flags = {}) {
1191
1249
  return { waitedMs: Date.now() - startedAt, attempts, remote };
1192
1250
  }
1193
1251
  const remoteHolder =
1194
- (typeof remote?.holder === 'string' ? remote.holder : null) ||
1195
- remote?.holder?.changeId ||
1196
1252
  remote?.changeId ||
1253
+ remote?.clientSessionId ||
1254
+ remote?.holder?.changeId ||
1197
1255
  remote?.holder?.clientSessionId ||
1256
+ (typeof remote?.holder === 'string' ? remote.holder : null) ||
1198
1257
  'another release';
1199
1258
  if (Date.now() >= deadline) {
1200
1259
  fail(
@@ -1210,9 +1269,20 @@ async function waitForPublishLeaseAvailability(config, target, flags = {}) {
1210
1269
  30000,
1211
1270
  Math.max(1000, deadline - Date.now())
1212
1271
  );
1213
- warn(
1214
- `发布队列等待中: app=${target.appType} holder=${remoteHolder} retry=${Math.ceil(delayMs / 1000)}s`
1215
- );
1272
+ const noticeFingerprint = [
1273
+ remoteHolder,
1274
+ remote?.leaseId || '',
1275
+ ].join(':');
1276
+ if (
1277
+ noticeFingerprint !== lastNoticeFingerprint ||
1278
+ Date.now() - lastNoticeAt >= 120_000
1279
+ ) {
1280
+ warn(
1281
+ `发布队列等待中: app=${target.appType} holder=${remoteHolder} retry=${Math.ceil(delayMs / 1000)}s${remote?.expiresAt ? ` expiresAt=${remote.expiresAt}` : ''}`
1282
+ );
1283
+ lastNoticeFingerprint = noticeFingerprint;
1284
+ lastNoticeAt = Date.now();
1285
+ }
1216
1286
  await sleep(delayMs);
1217
1287
  }
1218
1288
  }
@@ -1443,6 +1513,9 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1443
1513
  execution: null,
1444
1514
  };
1445
1515
  }
1516
+ const plannedSourceRevision = prepareReleaseSourceRevision({
1517
+ cwd: process.cwd(),
1518
+ });
1446
1519
  const existing = readReleaseExecution(changeId);
1447
1520
  if (
1448
1521
  existing &&
@@ -1462,6 +1535,11 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1462
1535
  'RELEASE_WRITE_REVIEW_REQUIRED: 上次写请求结果不确定;请先只读核对 staged release,确认可重试后追加 --resume-after-review'
1463
1536
  );
1464
1537
  }
1538
+ if (existing?.releaseSourceRevision) {
1539
+ assertReleaseSourceRevisionStable(existing.releaseSourceRevision, {
1540
+ cwd: process.cwd(),
1541
+ });
1542
+ }
1465
1543
  const now = new Date().toISOString();
1466
1544
  const execution = existing || {
1467
1545
  schemaVersion: 'openxiangda_release_execution_v1',
@@ -1469,6 +1547,7 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1469
1547
  profile: target.profileName,
1470
1548
  changeId,
1471
1549
  planHash,
1550
+ releaseSourceRevision: plannedSourceRevision,
1472
1551
  status: 'planned',
1473
1552
  createdAt: now,
1474
1553
  updatedAt: now,
@@ -1493,6 +1572,9 @@ async function publishWorkspaceRelease(config, target, flags = {}) {
1493
1572
  },
1494
1573
  ],
1495
1574
  };
1575
+ if (!execution.releaseSourceRevision) {
1576
+ execution.releaseSourceRevision = plannedSourceRevision;
1577
+ }
1496
1578
  if (execution.status === 'completed') {
1497
1579
  return {
1498
1580
  completed: true,
@@ -1662,7 +1744,7 @@ async function release(args) {
1662
1744
  ' - begin 获取应用级单写者 promotion lease;不同工作区仍可并行开发和验证。',
1663
1745
  ' - 带 --change 的 resource/runtime 写命令在没有本地 lease 时会自动 begin,并在后续命令复用。',
1664
1746
  ' - 子 Runtime/Page/Backend/Form Release 默认先 stage;app-finalize 用 --staged-resources-json 覆盖 capture 中的 changed children,并在同一事务中原子激活。',
1665
- ' - 默认 TTL 为 1800 秒;长异步发布每 30 秒检查并在到期前自动续租,续租失败会以 PUBLISH_LEASE_LOST 阻断后续写入。',
1747
+ ' - 默认 TTL 为 120 秒;活动发布每 30 秒心跳并在到期前续租,客户端退出后孤儿租约最多阻塞约 2 分钟。',
1666
1748
  ' - begin 仅接受 clean 且与权威远端默认 main/master 完全一致的 HEAD;feature branch 先合并并 push,再由主分支一次发布。',
1667
1749
  ' - 因发布源已在远端主线,成功激活后 integration-status 会立即通过,end 不再等待补合并。',
1668
1750
  ' - integration-status --check 可作为 CI 门禁;尚未回合时返回非零。',
@@ -2080,7 +2162,14 @@ async function loadTaskStatusSnapshot(config, target, changeId) {
2080
2162
  process.cwd(),
2081
2163
  changeId
2082
2164
  );
2083
- const integration = taskResult
2165
+ const execution = readReleaseExecution(changeId);
2166
+ const directPublish = readDirectPublishReceipt(changeId);
2167
+ const sourceRevision =
2168
+ execution?.releaseSourceRevision ||
2169
+ execution?.releaseContext?.releaseSourceRevision ||
2170
+ directPublish?.sourceRevision ||
2171
+ null;
2172
+ let integration = taskResult
2084
2173
  ? inspectIntegrationBundleCommits(
2085
2174
  {
2086
2175
  requiredCommits: [
@@ -2095,13 +2184,26 @@ async function loadTaskStatusSnapshot(config, target, changeId) {
2095
2184
  { cwd: process.cwd() }
2096
2185
  )
2097
2186
  : null;
2098
- const execution = readReleaseExecution(changeId);
2187
+ let integrationError = null;
2188
+ if (!integration && sourceRevision) {
2189
+ try {
2190
+ integration = inspectReleaseSourceIntegration(sourceRevision, {
2191
+ cwd: process.cwd(),
2192
+ });
2193
+ } catch (error) {
2194
+ integrationError = {
2195
+ code: error?.code || 'RELEASE_INTEGRATION_STATUS_UNAVAILABLE',
2196
+ message: maskText(error?.message || String(error)),
2197
+ };
2198
+ }
2199
+ }
2099
2200
  const localLease = getStoredPublishLease(target);
2100
2201
  const baseline = getStoredChangeBaseline(target, {
2101
2202
  access: 'reconciliation-read',
2102
2203
  });
2103
2204
  const clientSessionId =
2104
2205
  localLease?.clientSessionId ||
2206
+ execution?.releaseContext?.clientSessionId ||
2105
2207
  baseline?.clientSessionId ||
2106
2208
  createPublishClientSessionId();
2107
2209
  let remoteLease = null;
@@ -2145,6 +2247,8 @@ async function loadTaskStatusSnapshot(config, target, changeId) {
2145
2247
  taskResult,
2146
2248
  integration,
2147
2249
  execution,
2250
+ directPublish,
2251
+ sourceRevision,
2148
2252
  remoteLease,
2149
2253
  postCommit,
2150
2254
  }),
@@ -2152,6 +2256,7 @@ async function loadTaskStatusSnapshot(config, target, changeId) {
2152
2256
  diagnostics: {
2153
2257
  remoteLeaseError,
2154
2258
  postCommitError,
2259
+ integrationError,
2155
2260
  },
2156
2261
  };
2157
2262
  }
@@ -3918,6 +4023,7 @@ async function resolvePublishLeaseForWrite(config, target, flags = {}) {
3918
4023
  const explicitLeaseId = readStringFlag(flags, 'publish-lease-id');
3919
4024
  const changeId = readStringFlag(flags, 'change');
3920
4025
  const storedLease = getStoredPublishLease(target);
4026
+ let acquiredByCommand = false;
3921
4027
  assertOrClaimWorktreeOwner({
3922
4028
  cwd: process.cwd(),
3923
4029
  changeId: changeId || storedLease?.changeId || 'unscoped-publish',
@@ -3981,6 +4087,7 @@ async function resolvePublishLeaseForWrite(config, target, flags = {}) {
3981
4087
  ...flags,
3982
4088
  'client-session-id': clientSessionId,
3983
4089
  });
4090
+ acquiredByCommand = true;
3984
4091
  } catch (error) {
3985
4092
  clearRejectedPendingBaseline(target, baseline, error);
3986
4093
  throw error;
@@ -4017,6 +4124,17 @@ async function resolvePublishLeaseForWrite(config, target, flags = {}) {
4017
4124
  if (shouldRenewPublishLease(lease)) {
4018
4125
  lease = await heartbeat.beforeWrite({ forceRenew: true });
4019
4126
  }
4127
+ if (acquiredByCommand) {
4128
+ activeCommandScopedPublishLease = {
4129
+ config,
4130
+ target,
4131
+ leaseId: lease.leaseId,
4132
+ changeId: lease.changeId || changeId,
4133
+ writeAttempted: false,
4134
+ retain: false,
4135
+ retentionReason: null,
4136
+ };
4137
+ }
4020
4138
  return lease;
4021
4139
  }
4022
4140
 
@@ -4084,6 +4202,112 @@ async function stopActivePublishLeaseHeartbeat() {
4084
4202
  if (active?.heartbeat) await active.heartbeat.stop();
4085
4203
  }
4086
4204
 
4205
+ function markCommandScopedPublishWriteAttempted() {
4206
+ if (activeCommandScopedPublishLease) {
4207
+ activeCommandScopedPublishLease.writeAttempted = true;
4208
+ }
4209
+ }
4210
+
4211
+ function retainCommandScopedPublishLease(reason = 'staged-release') {
4212
+ if (!activeCommandScopedPublishLease) return false;
4213
+ activeCommandScopedPublishLease.retain = true;
4214
+ activeCommandScopedPublishLease.retentionReason = String(reason || 'staged-release');
4215
+ return true;
4216
+ }
4217
+
4218
+ async function finalizeCommandScopedPublishLease(options = {}) {
4219
+ const active = activeCommandScopedPublishLease;
4220
+ if (!active) return null;
4221
+ activeCommandScopedPublishLease = null;
4222
+ if (active.retain) {
4223
+ return {
4224
+ managed: true,
4225
+ released: false,
4226
+ retained: true,
4227
+ reason: active.retentionReason,
4228
+ };
4229
+ }
4230
+ if (options.succeeded === false && active.writeAttempted) {
4231
+ warn(
4232
+ `PUBLISH_WRITE_REVIEW_REQUIRED: change=${active.changeId || '-'} 的写请求结果未完整确认,租约已保留供只读核对和恢复`
4233
+ );
4234
+ return {
4235
+ managed: true,
4236
+ released: false,
4237
+ retained: true,
4238
+ reason: 'write-result-review',
4239
+ };
4240
+ }
4241
+
4242
+ await stopActivePublishLeaseHeartbeat();
4243
+ const stored = getStoredPublishLease(active.target);
4244
+ if (!stored || stored.leaseId !== active.leaseId) {
4245
+ return {
4246
+ managed: true,
4247
+ released: false,
4248
+ retained: false,
4249
+ reason: 'local-lease-already-cleared',
4250
+ };
4251
+ }
4252
+ const baseline = getStoredChangeBaseline(active.target, {
4253
+ access: 'reconciliation-read',
4254
+ });
4255
+ const integration = assertReleaseSourceIntegrated(
4256
+ baseline?.releaseSourceRevision,
4257
+ { cwd: process.cwd() }
4258
+ );
4259
+ try {
4260
+ await requestWithAuth(
4261
+ active.config,
4262
+ active.target.profileName,
4263
+ publishLeaseApiPath(
4264
+ active.target,
4265
+ `/${encodeURIComponent(active.leaseId)}/release`
4266
+ ),
4267
+ {
4268
+ method: 'POST',
4269
+ body: {
4270
+ completion:
4271
+ options.succeeded === false
4272
+ ? 'prewrite-failed'
4273
+ : 'direct-publish-completed',
4274
+ sourceCommit: integration.sourceCommit,
4275
+ mainBranch: integration.mainBranch,
4276
+ mainTipCommit: integration.mainTipCommit,
4277
+ },
4278
+ }
4279
+ );
4280
+ } catch (error) {
4281
+ if (
4282
+ !['PUBLISH_LEASE_EXPIRED', 'PUBLISH_LEASE_INVALID'].includes(
4283
+ error?.code
4284
+ )
4285
+ ) {
4286
+ activeCommandScopedPublishLease = active;
4287
+ throw error;
4288
+ }
4289
+ }
4290
+ clearPublishLease(active.target, active.leaseId);
4291
+ if (baseline) {
4292
+ clearChangeBaseline(
4293
+ active.target,
4294
+ baseline.baselineId || baseline.id
4295
+ );
4296
+ }
4297
+ releaseWorktreeOwner({ cwd: process.cwd() });
4298
+ activePublishRequestContext = null;
4299
+ return {
4300
+ managed: true,
4301
+ released: true,
4302
+ retained: false,
4303
+ completion:
4304
+ options.succeeded === false
4305
+ ? 'prewrite-failed'
4306
+ : 'direct-publish-completed',
4307
+ integration,
4308
+ };
4309
+ }
4310
+
4087
4311
  function publishContextRequiredError(label, detail) {
4088
4312
  const suffix = detail ? `(${detail})` : '';
4089
4313
  const error = new Error(
@@ -5773,7 +5997,12 @@ async function workspace(args) {
5773
5997
  flags
5774
5998
  );
5775
5999
  }
5776
- runReactSpaFormSchemaPublish(profileName, profile, bound.appType, publishOptions);
6000
+ await runReactSpaFormSchemaPublish(
6001
+ profileName,
6002
+ profile,
6003
+ bound.appType,
6004
+ publishOptions
6005
+ );
5777
6006
  } else {
5778
6007
  fail(
5779
6008
  [
@@ -5794,7 +6023,12 @@ async function workspace(args) {
5794
6023
  flags
5795
6024
  );
5796
6025
  }
5797
- runWorkspacePublish(profileName, profile, bound.appType, publishOptions.workspaceArgs);
6026
+ await runWorkspacePublish(
6027
+ profileName,
6028
+ profile,
6029
+ bound.appType,
6030
+ publishOptions.workspaceArgs
6031
+ );
5798
6032
  }
5799
6033
  if (publishOptions.includeResources) {
5800
6034
  if (publishOptions.dryRun) {
@@ -5828,6 +6062,7 @@ async function workspace(args) {
5828
6062
  } else if (!publishOptions.quietResourceSkip) {
5829
6063
  print('已跳过 src/resources 发布;如需同时发布资源,传 --resources。');
5830
6064
  }
6065
+ await finalizeCommandScopedPublishLease({ succeeded: true });
5831
6066
  return;
5832
6067
  }
5833
6068
 
@@ -6644,6 +6879,12 @@ async function page(args) {
6644
6879
  },
6645
6880
  }
6646
6881
  );
6882
+ const leaseCompletion = await finalizeCommandScopedPublishLease({
6883
+ succeeded: true,
6884
+ });
6885
+ if (leaseCompletion && data && typeof data === 'object') {
6886
+ data.publishLeaseLifecycle = leaseCompletion;
6887
+ }
6647
6888
  return outputPageResult(data, flags);
6648
6889
  }
6649
6890
 
@@ -6719,6 +6960,15 @@ async function page(args) {
6719
6960
  legacyFormUuid: item.legacyFormUuid,
6720
6961
  });
6721
6962
  }
6963
+ if (!activate) {
6964
+ retainCommandScopedPublishLease('staged-page');
6965
+ }
6966
+ const leaseCompletion = await finalizeCommandScopedPublishLease({
6967
+ succeeded: true,
6968
+ });
6969
+ if (leaseCompletion && data && typeof data === 'object') {
6970
+ data.publishLeaseLifecycle = leaseCompletion;
6971
+ }
6722
6972
  return outputPageResult(data, flags);
6723
6973
  }
6724
6974
 
@@ -10347,6 +10597,8 @@ async function resource(args) {
10347
10597
  : [];
10348
10598
  const stagedChangeId =
10349
10599
  flags.change || getStoredChangeBaseline(target)?.changeId;
10600
+ const directPublishSourceRevision =
10601
+ getStoredChangeBaseline(target)?.releaseSourceRevision || null;
10350
10602
  if (stagedResources.length > 0 && stagedChangeId) {
10351
10603
  result.stagedResourcesFile = recordStagedAppReleaseResources(
10352
10604
  stagedChangeId,
@@ -10366,7 +10618,42 @@ async function resource(args) {
10366
10618
  target
10367
10619
  );
10368
10620
  }
10621
+ if (stagedResources.length > 0 || flags['stage-only']) {
10622
+ retainCommandScopedPublishLease('staged-resource');
10623
+ }
10624
+ const leaseCompletion = await telemetry.runPhase(
10625
+ 'lease-release',
10626
+ async () =>
10627
+ finalizeCommandScopedPublishLease({
10628
+ succeeded: true,
10629
+ })
10630
+ );
10631
+ if (leaseCompletion) {
10632
+ result.publishLeaseLifecycle = leaseCompletion;
10633
+ }
10369
10634
  result.timings = telemetry.stop();
10635
+ if (leaseCompletion?.released && stagedChangeId) {
10636
+ const receipt = writeDirectPublishReceipt(stagedChangeId, {
10637
+ command: 'resource publish',
10638
+ writeAttempted:
10639
+ (result.published || []).some(item => item.action !== 'noop') ||
10640
+ (result.deleted || []).length > 0,
10641
+ selectors: [
10642
+ ...(result.published || []).map(
10643
+ item => `${item.kind || 'resource'}:${item.code}`
10644
+ ),
10645
+ ...(result.deleted || []).map(
10646
+ item => `${item.kind || 'resource'}:${item.code}`
10647
+ ),
10648
+ ],
10649
+ sourceRevision: directPublishSourceRevision,
10650
+ result: {
10651
+ published: (result.published || []).length,
10652
+ deleted: (result.deleted || []).length,
10653
+ },
10654
+ });
10655
+ result.directPublishReceipt = receipt?.file || null;
10656
+ }
10370
10657
  if (flags.json) return writeJson(result);
10371
10658
  printResourceResult(result);
10372
10659
  } finally {
@@ -10599,6 +10886,10 @@ async function runtime(args) {
10599
10886
  target,
10600
10887
  flags
10601
10888
  );
10889
+ const directChangeId =
10890
+ flags.change || getStoredChangeBaseline(target)?.changeId;
10891
+ const directSourceRevision =
10892
+ getStoredChangeBaseline(target)?.releaseSourceRevision || null;
10602
10893
  await runRuntimeChangeBaselinePreflight(config, target);
10603
10894
  const activationPreconditions = await resolveRuntimeActivationPreconditions(
10604
10895
  config,
@@ -10623,6 +10914,18 @@ async function runtime(args) {
10623
10914
  }
10624
10915
  );
10625
10916
  saveRuntimeReleaseState(target, data);
10917
+ const leaseCompletion = await finalizeCommandScopedPublishLease({
10918
+ succeeded: true,
10919
+ });
10920
+ if (leaseCompletion?.released && directChangeId) {
10921
+ writeDirectPublishReceipt(directChangeId, {
10922
+ command: 'runtime activate',
10923
+ writeAttempted: true,
10924
+ selectors: ['runtime:active'],
10925
+ sourceRevision: directSourceRevision,
10926
+ result: { releaseId: data?.id || releaseId },
10927
+ });
10928
+ }
10626
10929
  if (flags.json) return writeJson(data);
10627
10930
  print(`runtime release 已激活: ${data.id || releaseId} build=${data.buildId || '-'}`);
10628
10931
  return;
@@ -10666,8 +10969,8 @@ async function runtime(args) {
10666
10969
  // Keep build after lease acquisition. In direct-storage modes the build
10667
10970
  // embeds the provider-specific assetBaseUrl returned by the protected
10668
10971
  // 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.
10972
+ // behavior. The async build keeps the short publish-lease heartbeat alive
10973
+ // instead of extending a crash-prone one-hour lease.
10671
10974
  let publishLease = await telemetry.runPhase('lease', async () =>
10672
10975
  resolveRuntimePublishLeaseForWrite(config, target, flags)
10673
10976
  );
@@ -10748,19 +11051,8 @@ async function runtime(args) {
10748
11051
  }
10749
11052
  }
10750
11053
  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
- );
11054
+ // Runtime builds run in an asynchronous child process so the promotion
11055
+ // heartbeat keeps renewing the short lease while compilation proceeds.
10764
11056
  await telemetry.runPhase('build', async () =>
10765
11057
  runRuntimeBuild({
10766
11058
  buildId,
@@ -10952,8 +11244,35 @@ async function runtime(args) {
10952
11244
  traceId,
10953
11245
  ...(stagedResource ? { stagedResource } : {}),
10954
11246
  ...(stagedResourcesFile ? { stagedResourcesFile } : {}),
10955
- timings: telemetry.stop(),
10956
11247
  };
11248
+ if (stagedResource) {
11249
+ retainCommandScopedPublishLease('staged-runtime');
11250
+ }
11251
+ const leaseCompletion = await telemetry.runPhase(
11252
+ 'lease-release',
11253
+ async () =>
11254
+ finalizeCommandScopedPublishLease({
11255
+ succeeded: true,
11256
+ })
11257
+ );
11258
+ if (leaseCompletion) {
11259
+ result.publishLeaseLifecycle = leaseCompletion;
11260
+ }
11261
+ result.timings = telemetry.stop();
11262
+ if (leaseCompletion?.released && stagedChangeId) {
11263
+ const receipt = writeDirectPublishReceipt(stagedChangeId, {
11264
+ command: 'runtime deploy',
11265
+ writeAttempted: true,
11266
+ selectors: ['runtime:active'],
11267
+ sourceRevision: runtimeLineage.sourceRevision,
11268
+ result: {
11269
+ releaseId: data?.id || null,
11270
+ buildId,
11271
+ activated: true,
11272
+ },
11273
+ });
11274
+ result.directPublishReceipt = receipt?.file || null;
11275
+ }
10957
11276
  if (flags.json) return writeJson(result);
10958
11277
  print(
10959
11278
  [
@@ -11056,32 +11375,61 @@ async function createRuntimeOssUploadBasePlan(options) {
11056
11375
  );
11057
11376
  }
11058
11377
 
11059
- function runRuntimeBuild(options) {
11378
+ async function runRuntimeBuild(options) {
11060
11379
  const command = options.command || defaultRuntimeBuildCommand();
11061
11380
  if (options.jsonOutput) {
11062
11381
  printRuntimeProgress(`构建 React SPA runtime: ${command}`);
11063
11382
  } else {
11064
11383
  print(`构建 React SPA runtime: ${command}`);
11065
11384
  }
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
- },
11385
+ await new Promise((resolve, reject) => {
11386
+ let child;
11387
+ try {
11388
+ child = spawn(command, [], {
11389
+ cwd: process.cwd(),
11390
+ shell: true,
11391
+ stdio: options.jsonOutput
11392
+ ? ['ignore', 'pipe', 'pipe']
11393
+ : 'inherit',
11394
+ env: {
11395
+ ...process.env,
11396
+ OPENXIANGDA_APP_TYPE: options.appType,
11397
+ APP_TYPE: options.appType,
11398
+ OPENXIANGDA_BUILD_ID: options.buildId,
11399
+ OPENXIANGDA_RUNTIME_ASSET_BASE: options.assetBaseUrl,
11400
+ },
11401
+ });
11402
+ } catch (error) {
11403
+ reject(
11404
+ new Error(`runtime build 无法启动: ${error?.message || String(error)}`)
11405
+ );
11406
+ return;
11407
+ }
11408
+ if (options.jsonOutput) {
11409
+ child.stdout?.on('data', chunk => {
11410
+ process.stderr.write(maskText(String(chunk)));
11411
+ });
11412
+ child.stderr?.on('data', chunk => {
11413
+ process.stderr.write(maskText(String(chunk)));
11414
+ });
11415
+ }
11416
+ child.once('error', error => {
11417
+ reject(new Error(`runtime build 无法启动: ${error.message}`));
11418
+ });
11419
+ child.once('close', (code, signal) => {
11420
+ if (code === 0) {
11421
+ resolve();
11422
+ return;
11423
+ }
11424
+ reject(
11425
+ new Error(
11426
+ `runtime build 失败: ${
11427
+ Number.isInteger(code) ? `exit ${code}` : `signal ${signal || '-'}`
11428
+ }`
11429
+ )
11430
+ );
11431
+ });
11078
11432
  });
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
11433
  }
11086
11434
 
11087
11435
  function assertRuntimeBuildReady(explicitCommand) {
@@ -23461,6 +23809,32 @@ function publishContextHeadersForRequest(config, profileName, apiPath, options =
23461
23809
  );
23462
23810
  }
23463
23811
 
23812
+ function isPublishMutationRequest(apiPath, options = {}) {
23813
+ const method = String(options.method || 'GET').toUpperCase();
23814
+ if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return false;
23815
+ const normalizedPath = String(apiPath || '').split('?')[0];
23816
+ if (
23817
+ normalizedPath.includes('/publish-lease/') ||
23818
+ normalizedPath.endsWith('/publish-lease/acquire') ||
23819
+ normalizedPath.includes('/change-baselines')
23820
+ ) {
23821
+ return false;
23822
+ }
23823
+ if (
23824
+ normalizedPath.endsWith('/oss-upload-plan') &&
23825
+ options.body?.planOnly === true
23826
+ ) {
23827
+ return false;
23828
+ }
23829
+ if (
23830
+ normalizedPath.endsWith('/schema-storage-plan') ||
23831
+ normalizedPath.endsWith('/storage-plan')
23832
+ ) {
23833
+ return false;
23834
+ }
23835
+ return true;
23836
+ }
23837
+
23464
23838
  async function requestWithAuth(config, profileName, apiPath, options = {}) {
23465
23839
  assertReadOnlyHttpRequest(apiPath, options);
23466
23840
  const resolved = getProfile(config, profileName);
@@ -23482,6 +23856,9 @@ async function requestWithAuth(config, profileName, apiPath, options = {}) {
23482
23856
  ) {
23483
23857
  await activePublishLeaseHeartbeat?.heartbeat?.beforeWrite();
23484
23858
  }
23859
+ if (isPublishMutationRequest(apiPath, requestOptions)) {
23860
+ markCommandScopedPublishWriteAttempted();
23861
+ }
23485
23862
  // Build headers after the lease check: a successful renew may replace the
23486
23863
  // live client session/expiry metadata, and auth/transient retries must not
23487
23864
  // replay stale publish context.
@@ -23542,6 +23919,7 @@ async function requestFormWithAuth(config, profileName, apiPath, formDataFactory
23542
23919
  if (!skipPublishLeaseHeartbeat) {
23543
23920
  await activePublishLeaseHeartbeat?.heartbeat?.beforeWrite();
23544
23921
  }
23922
+ markCommandScopedPublishWriteAttempted();
23545
23923
  const currentRequestOptions = {
23546
23924
  ...requestOptions,
23547
23925
  headers: {
@@ -23759,7 +24137,7 @@ function normalizeWorkspacePublishOptions(flags) {
23759
24137
  };
23760
24138
  }
23761
24139
 
23762
- function runWorkspacePublish(profileName, profile, appType, publishArgs = []) {
24140
+ async function runWorkspacePublish(profileName, profile, appType, publishArgs = []) {
23763
24141
  const packageFile = path.join(process.cwd(), 'package.json');
23764
24142
  if (!fs.existsSync(packageFile)) {
23765
24143
  fail('当前目录没有 package.json,无法识别工作区发布脚本');
@@ -23781,14 +24159,13 @@ function runWorkspacePublish(profileName, profile, appType, publishArgs = []) {
23781
24159
  ? ['run', scriptName, '--', ...publishArgs]
23782
24160
  : ['run', scriptName];
23783
24161
  const globalEnv = loadGlobalEnv();
23784
- const result = spawnSync(command, args, {
24162
+ await runWorkspaceChildCommand(command, args, {
23785
24163
  cwd: process.cwd(),
23786
- stdio: 'inherit',
23787
24164
  env: buildWorkspacePublishEnv(profileName, profile, appType, globalEnv),
24165
+ failureCode: 'WORKSPACE_PUBLISH_FAILED',
24166
+ label: scriptName,
24167
+ marksPublishWrite: true,
23788
24168
  });
23789
- if (result.status !== 0) {
23790
- process.exit(result.status || 1);
23791
- }
23792
24169
  }
23793
24170
 
23794
24171
  function isReactSpaWorkspace() {
@@ -23798,7 +24175,12 @@ function isReactSpaWorkspace() {
23798
24175
  return /runtimeMode\s*:\s*['"]react-spa['"]/.test(content);
23799
24176
  }
23800
24177
 
23801
- function runReactSpaFormSchemaPublish(profileName, profile, appType, publishOptions) {
24178
+ async function runReactSpaFormSchemaPublish(
24179
+ profileName,
24180
+ profile,
24181
+ appType,
24182
+ publishOptions
24183
+ ) {
23802
24184
  const packageFile = path.join(process.cwd(), 'package.json');
23803
24185
  if (!fs.existsSync(packageFile)) {
23804
24186
  fail('当前目录没有 package.json,无法识别工作区发布脚本');
@@ -23818,14 +24200,41 @@ function runReactSpaFormSchemaPublish(profileName, profile, appType, publishOpti
23818
24200
  `React SPA 表单 schema 发布${publishOptions.dryRun ? ' (dry-run)' : ''}: ${publishOptions.targetForm}`
23819
24201
  );
23820
24202
  const globalEnv = loadGlobalEnv();
23821
- const result = spawnSync(command, args, {
24203
+ await runWorkspaceChildCommand(command, args, {
23822
24204
  cwd: process.cwd(),
23823
- stdio: 'inherit',
23824
24205
  env: buildWorkspacePublishEnv(profileName, profile, appType, globalEnv),
24206
+ failureCode: 'FORM_SCHEMA_PUBLISH_FAILED',
24207
+ label: `form schema ${publishOptions.targetForm}`,
24208
+ marksPublishWrite: true,
24209
+ });
24210
+ }
24211
+
24212
+ async function runWorkspaceChildCommand(command, args, options = {}) {
24213
+ await new Promise((resolve, reject) => {
24214
+ const child = spawn(command, args, {
24215
+ cwd: options.cwd || process.cwd(),
24216
+ stdio: 'inherit',
24217
+ env: options.env || process.env,
24218
+ });
24219
+ child.once('spawn', () => {
24220
+ if (options.marksPublishWrite) {
24221
+ markCommandScopedPublishWriteAttempted();
24222
+ }
24223
+ });
24224
+ child.once('error', reject);
24225
+ child.once('close', (code, signal) => {
24226
+ if (code === 0) {
24227
+ resolve();
24228
+ return;
24229
+ }
24230
+ const error = new Error(
24231
+ `${options.label || command} 执行失败${signal ? `(signal=${signal})` : `(exit=${code ?? 1})`}`
24232
+ );
24233
+ error.code = options.failureCode || 'WORKSPACE_CHILD_COMMAND_FAILED';
24234
+ error.exitCode = Number.isInteger(code) ? code : 1;
24235
+ reject(error);
24236
+ });
23825
24237
  });
23826
- if (result.status !== 0) {
23827
- process.exit(result.status || 1);
23828
- }
23829
24238
  }
23830
24239
 
23831
24240
  function buildWorkspacePublishEnv(profileName, profile, appType, globalEnv) {
@@ -1,5 +1,5 @@
1
1
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
2
- const DEFAULT_RENEW_WINDOW_MS = 180_000;
2
+ const DEFAULT_RENEW_WINDOW_MS = 60_000;
3
3
 
4
4
  class PublishLeaseLostError extends Error {
5
5
  constructor(message, cause) {
@@ -4,8 +4,8 @@ const path = require('path');
4
4
 
5
5
  const { saveProjectState } = require('./config');
6
6
 
7
- const DEFAULT_CLI_PUBLISH_LEASE_TTL_SECONDS = 1800;
8
- const PUBLISH_LEASE_RENEW_WINDOW_SECONDS = 180;
7
+ const DEFAULT_CLI_PUBLISH_LEASE_TTL_SECONDS = 120;
8
+ const PUBLISH_LEASE_RENEW_WINDOW_SECONDS = 60;
9
9
 
10
10
  function getStoredPublishLease(target) {
11
11
  const lease = target?.bound?.promotion?.publishLease;
@@ -1,5 +1,6 @@
1
1
  function buildTaskStatus(input = {}) {
2
2
  const execution = object(input.execution);
3
+ const directPublish = object(input.directPublish);
3
4
  const steps = Array.isArray(execution?.steps) ? execution.steps : [];
4
5
  const completed = steps.filter(step => step.status === 'completed');
5
6
  const failed = steps.find(step => step.status === 'failed') || null;
@@ -7,12 +8,28 @@ function buildTaskStatus(input = {}) {
7
8
  steps.find(step => ['running', 'write-started'].includes(step.status)) ||
8
9
  null;
9
10
  const pending = steps.filter(step => step.status === 'pending');
10
- const releaseState = execution?.status || 'not-started';
11
+ const releaseState =
12
+ execution?.status ||
13
+ (directPublish?.status === 'completed'
14
+ ? 'direct-publish-completed'
15
+ : 'not-started');
11
16
  const postCommit = object(input.postCommit);
12
17
  const remoteLease = object(input.remoteLease);
13
18
  const taskResult = object(input.taskResult);
14
19
  const integration = object(input.integration);
15
20
  const change = object(input.change);
21
+ const sourceRevision =
22
+ object(input.sourceRevision) ||
23
+ object(directPublish?.sourceRevision) ||
24
+ object(execution?.releaseSourceRevision) ||
25
+ object(execution?.releaseContext?.releaseSourceRevision);
26
+ const changeId =
27
+ input.changeId || change?.id || execution?.changeId || null;
28
+ const leaseBlocked = isRemoteLeaseBlocking({
29
+ remoteLease,
30
+ execution,
31
+ changeId,
32
+ });
16
33
  const phase = inferPhase({
17
34
  releaseState,
18
35
  steps,
@@ -29,14 +46,19 @@ function buildTaskStatus(input = {}) {
29
46
  remoteLease,
30
47
  integration,
31
48
  postCommit,
49
+ leaseBlocked,
32
50
  });
33
51
  const startedAt =
34
52
  execution?.createdAt ||
53
+ directPublish?.completedAt ||
35
54
  change?.createdAt ||
36
55
  taskResult?.recordedAt ||
37
56
  null;
38
57
  const endedAt =
39
58
  execution?.completedAt ||
59
+ (releaseState === 'direct-publish-completed'
60
+ ? directPublish?.completedAt
61
+ : null) ||
40
62
  (phase === 'integration-ready' ? taskResult?.recordedAt : null);
41
63
  const now = Number.isFinite(Number(input.now))
42
64
  ? Number(input.now)
@@ -49,17 +71,18 @@ function buildTaskStatus(input = {}) {
49
71
  running,
50
72
  );
51
73
  const wrotePlatform = Boolean(
52
- execution?.writeAttempted ||
74
+ directPublish?.writeAttempted ||
75
+ execution?.writeAttempted ||
53
76
  execution?.stagedWriteOccurred ||
54
77
  completed.some(step => step.id !== 'lease-and-capture'),
55
78
  );
56
79
  const healthy =
57
- releaseState === 'completed' &&
80
+ ['completed', 'direct-publish-completed'].includes(releaseState) &&
58
81
  (!postCommit || postCommit.status === 'completed');
59
82
 
60
83
  return {
61
84
  schemaVersion: 'openxiangda_task_status_v1',
62
- changeId: input.changeId || change?.id || execution?.changeId || null,
85
+ changeId,
63
86
  phase,
64
87
  healthy,
65
88
  wrotePlatform,
@@ -73,8 +96,8 @@ function buildTaskStatus(input = {}) {
73
96
  },
74
97
  source: {
75
98
  changeStatus: change?.status || null,
76
- taskCommit: taskResult?.commit || null,
77
- taskReady: Boolean(taskResult),
99
+ taskCommit: taskResult?.commit || sourceRevision?.baseCommit || null,
100
+ taskReady: Boolean(taskResult || sourceRevision),
78
101
  integrated: inferIntegrated(integration),
79
102
  },
80
103
  release: {
@@ -86,6 +109,10 @@ function buildTaskStatus(input = {}) {
86
109
  remoteLease?.holder?.changeId ||
87
110
  remoteLease?.holder?.clientSessionId ||
88
111
  remoteLease?.changeId ||
112
+ remoteLease?.clientSessionId ||
113
+ (typeof remoteLease?.holder === 'string'
114
+ ? remoteLease.holder
115
+ : null) ||
89
116
  null,
90
117
  },
91
118
  blocker: blockingLayer
@@ -100,7 +127,7 @@ function buildTaskStatus(input = {}) {
100
127
  message:
101
128
  failed?.error?.message ||
102
129
  failed?.error ||
103
- blockerMessage(blockingLayer),
130
+ blockerMessage(blockingLayer, remoteLease),
104
131
  }
105
132
  : null,
106
133
  nextAction: nextAction({
@@ -111,6 +138,7 @@ function buildTaskStatus(input = {}) {
111
138
  postCommit,
112
139
  taskResult,
113
140
  integration,
141
+ leaseBlocked,
114
142
  }),
115
143
  };
116
144
  }
@@ -124,6 +152,7 @@ function inferPhase(input) {
124
152
  return 'health';
125
153
  }
126
154
  if (input.releaseState === 'completed') return 'healthy';
155
+ if (input.releaseState === 'direct-publish-completed') return 'healthy';
127
156
  if (input.releaseState === 'write-review-required') return 'write-review';
128
157
  if (input.failed) return 'failed';
129
158
  if (input.running) return phaseFromStep(input.running.id);
@@ -163,13 +192,7 @@ function inferBlockingLayer(input) {
163
192
  if (input.postCommit?.status && input.postCommit.status !== 'completed') {
164
193
  return 'post-commit';
165
194
  }
166
- if (
167
- input.remoteLease?.active &&
168
- input.releaseState !== 'running' &&
169
- input.releaseState !== 'staged-resumable'
170
- ) {
171
- return 'lease';
172
- }
195
+ if (input.leaseBlocked) return 'lease';
173
196
  if (input.integration && inferIntegrated(input.integration) === false) {
174
197
  return 'git-mainline';
175
198
  }
@@ -231,13 +254,26 @@ function nextAction(input) {
231
254
  if (input.failed) {
232
255
  return `修复 ${input.failed.id || '失败步骤'} 后重跑同一条 release publish。`;
233
256
  }
234
- if (input.remoteLease?.active && input.releaseState === 'not-started') {
235
- return '等待当前应用发布租约释放;开发与测试可继续并行。';
257
+ if (input.leaseBlocked) {
258
+ const holder =
259
+ input.remoteLease?.changeId ||
260
+ input.remoteLease?.clientSessionId ||
261
+ input.remoteLease?.holder?.changeId ||
262
+ input.remoteLease?.holder?.clientSessionId ||
263
+ input.remoteLease?.holder ||
264
+ '另一发布任务';
265
+ const expiry = input.remoteLease?.expiresAt
266
+ ? `,最晚 ${input.remoteLease.expiresAt} 到期`
267
+ : '';
268
+ return `等待 ${holder} 释放当前应用租约${expiry};本任务尚未写平台,可继续本地开发与测试。`;
236
269
  }
237
270
  if (input.releaseState === 'staged-resumable') {
238
271
  return '重跑同一条 release publish,从已验证的 staged child 继续。';
239
272
  }
240
273
  if (input.releaseState === 'completed') return '执行业务验收并归档 change。';
274
+ if (input.releaseState === 'direct-publish-completed') {
275
+ return '直接发布已完成;执行业务验收并归档 change。';
276
+ }
241
277
  if (input.taskResult && inferIntegrated(input.integration) === false) {
242
278
  return '把 task commit 合并并推送到权威主分支,再创建 mainline bundle。';
243
279
  }
@@ -245,9 +281,11 @@ function nextAction(input) {
245
281
  return '完成实现和聚焦测试,提交后运行 sdd ready。';
246
282
  }
247
283
 
248
- function blockerMessage(layer) {
284
+ function blockerMessage(layer, remoteLease) {
249
285
  const messages = {
250
- lease: '另一个发布任务持有应用租约。',
286
+ lease: remoteLease?.changeId
287
+ ? `发布任务 ${remoteLease.changeId} 持有应用租约。`
288
+ : '另一个发布任务持有应用租约。',
251
289
  'git-mainline': '任务提交尚未完整进入权威主分支。',
252
290
  'post-commit': '发布已激活,提交后动作仍在自动重试。',
253
291
  'write-result': '上次写请求结果不确定,需要只读核对。',
@@ -255,6 +293,31 @@ function blockerMessage(layer) {
255
293
  return messages[layer] || '当前阶段需要处理失败后才能继续。';
256
294
  }
257
295
 
296
+ function isRemoteLeaseBlocking(input = {}) {
297
+ const remote = object(input.remoteLease);
298
+ if (!remote?.active) return false;
299
+ const execution = object(input.execution);
300
+ const context = object(execution?.releaseContext);
301
+ if (
302
+ remote.leaseId &&
303
+ context?.leaseId &&
304
+ String(remote.leaseId) === String(context.leaseId)
305
+ ) {
306
+ return false;
307
+ }
308
+ if (
309
+ remote.clientSessionId &&
310
+ context?.clientSessionId &&
311
+ String(remote.clientSessionId) === String(context.clientSessionId) &&
312
+ (!remote.changeId ||
313
+ !input.changeId ||
314
+ String(remote.changeId) === String(input.changeId))
315
+ ) {
316
+ return false;
317
+ }
318
+ return true;
319
+ }
320
+
258
321
  function object(value) {
259
322
  return value && typeof value === 'object' && !Array.isArray(value)
260
323
  ? value
@@ -10,6 +10,13 @@ OpenXiangda connects an AI coding workspace to the private low-code platform thr
10
10
 
11
11
  This file is a router and safety card. Read only the one or two subskills selected below; do not load every OpenXiangda reference into the same turn.
12
12
 
13
+ ## Keep the agent loop small
14
+
15
+ - Start with one broad CodeGraph exploration that names the complete flow or feature. Use at most one focused follow-up when the first result explicitly omits a required symbol; do not repeat overlapping surveys.
16
+ - Ordinary work uses this router plus one domain subskill. Add a second domain subskill only when the accepted scope genuinely crosses domains; three or more OpenXiangda subskills require stopping and narrowing the task.
17
+ - When the user already supplied concrete requirements and acceptance criteria, record the structured SDD scope and implement. Do not turn a resolved request into a long design essay or ask for a duplicate confirmation.
18
+ - During an unchanged lease/build wait, report the first blocker and then only material state changes. Prefer `openxiangda task status --watch` over repeated narrative updates.
19
+
13
20
  ## Decide the track
14
21
 
15
22
  | Intent | Read next | First action |
@@ -27,7 +34,7 @@ This file is a router and safety card. Read only the one or two subskills select
27
34
  ## Scope before work
28
35
 
29
36
  1. Work from the app workspace root and pass `--profile <name>` to every write or release command.
30
- 2. In a shared or dirty checkout, use an isolated Git worktree/branch for development. Merge approved task commits into the authoritative remote default branch before any live release. Formal publish runs once from a clean local `main`/`master` that exactly equals the remote tip; feature worktrees never publish.
37
+ 2. In a shared app repository, reserve the canonical `main`/`master` checkout for integration and release. Every development task uses an isolated Git worktree/branch from the latest remote mainline; never stash, restore, or overwrite another task's changes to make the canonical checkout publishable. Merge approved task commits into the authoritative remote default branch before any live release. Formal publish runs once from a clean local `main`/`master` that exactly equals the remote tip; feature worktrees never publish.
31
38
  3. Give the task one stable SDD change id. Use `--change <id>` on context, plan, check, verify, publish, and archive commands when supported.
32
39
  4. Plan and publish exact resource codes with `--only <codes>` or `--code <code>`; a type-only or full-resource publish is allowed only when the dependency closure intentionally contains the whole type/application.
33
40
  5. A React SPA page change rebuilds one application runtime. Commit the complete build input first, upload/preview with `runtime deploy --no-activate`, and promote only from a clean merged release head that descends from the online Runtime source revision.
@@ -24,7 +24,9 @@ openxiangda sdd context --change <change> --changed --json
24
24
 
25
25
  Run `openxiangda update check --json` once per substantial task, on a suspected mismatch, or when the cached check is older than one day. If an update is installed, refresh skills once with `openxiangda skill install --force`.
26
26
 
27
- Every write/release command must include `--profile <name>`. Parallel tasks develop in isolated Git worktrees/branches, but do not publish there. Merge approved commits into the authoritative remote default branch, create one `sdd bundle` for the intended changes, commit/push it, and publish once from a clean local `main`/`master` that exactly equals the remote tip.
27
+ Every write/release command must include `--profile <name>`. In a shared app repository the canonical main checkout is integration/release-only; parallel tasks develop in isolated Git worktrees/branches and never stash or restore another task's files. Merge approved commits into the authoritative remote default branch, create one `sdd bundle` for the intended changes, commit/push it, and publish once from a clean local `main`/`master` that exactly equals the remote tip.
28
+
29
+ Keep investigation proportional: one complete CodeGraph survey plus at most one focused follow-up, normally one domain subskill, and no optional prose for a request whose requirements and acceptance criteria are already explicit. Unchanged lease waits should use `task status --watch` and emit only material transitions.
28
30
 
29
31
  SDD is structured-first by default: only `change.json`, `coverage.json`, and `release.json` are created. Generate optional prose with `openxiangda sdd render <change>`; missing checklist/evidence/spec prose is a warning, while approval, exact structured scope, actual argv, mainline identity, child CAS, lease, and atomic activation remain hard gates. Set `strictDocumentation: true` only when prose completion must intentionally block a workspace.
30
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda",
3
- "version": "1.0.176",
3
+ "version": "1.0.177",
4
4
  "description": "OpenXiangda CLI, workspace build tools, runtime SDK, and form components.",
5
5
  "private": false,
6
6
  "bin": {
@@ -5,10 +5,11 @@
5
5
 
6
6
  ## 开发原则
7
7
 
8
- - 架构类需求默认只规划、不实现。新应用、复杂页面、登录注册、公开访问、权限数据范围、流程自动化、连接器/通知等需求,先运行 `openxiangda doctor --json` 和 `openxiangda design gates --topic <code> --json`,输出设计并等用户确认;确认前只允许读取、快照、dry-run、提问和写设计文档,不允许改源码、写平台、发布或发送通知。
8
+ - 架构类需求先运行 `openxiangda doctor --json` 和 `openxiangda design gates --topic <code> --json`。只有仍存在会改变实现方向的业务、安全或数据选择时才输出设计并等待确认;用户已经给出具体需求与验收标准时,直接记录结构化 SDD 范围并实现,不再写长篇设计或重复确认。
9
9
  - 先按风险分级:只读/文档/测试为 L0;纯文案样式或单一既有资源绑定等可逆窄改为 L1,可用受限的 `openxiangda sdd quick` 记录精确范围;表单结构、业务函数、自动化/流程、权限、登录/公开访问、数据写入和 runtime/config 为 L2;不可逆、生产迁移或应用级扩权为 L3。L2/L3 必须挂完整 SDD,并由 `coverage.json` 把需求与场景映射到精确的文件、表单、页面及工程资源范围。
10
10
  - 一个开发任务只使用一个显式 change,并从 `openxiangda sdd context --change <change> --changed --json` 开始;默认只维护 change/coverage/release 三份结构化事实源,需要文档时才执行 `openxiangda sdd render <change>`。结构化 approval、资源和文件范围是硬约束,任务/证据/规格文案默认只告警。只有明确配置 `strictDocumentation: true` 才把文案完成度恢复为门禁。
11
- - 多会话开发使用独立 Git worktree/branch,但 feature worktree 不发布。把已批准提交合并并 push 到权威默认主分支后,用 `sdd bundle <release-change> --changes ...` 聚合范围;只从与远端 tip 完全一致的 clean `main`/`master` 一次发布。
11
+ - 多会话开发把规范的 `main`/`master` 检出专用于集成和发布;每个开发任务从最新远端主线使用独立 Git worktree/branch,禁止为了发布 stash/restore 或覆盖其他任务的文件。feature worktree 不发布。把已批准提交合并并 push 到权威默认主分支后,用 `sdd bundle <release-change> --changes ...` 聚合范围;只从与远端 tip 完全一致的 clean `main`/`master` 一次发布。
12
+ - 调查预算默认为一次覆盖完整调用链的 CodeGraph 查询,只有明确缺失符号时再补一次精确查询;普通任务只加载一个领域技能。租约、构建或部署状态未变化时不重复输出相同进度,使用 `openxiangda task status --watch` 等待状态变化。
12
13
  - 账号、角色、权限、数据范围、组织账号、RBAC、查询参数授权需求必须先运行 `openxiangda design gates --topic permissions --json`,选择 `managed-platform-account` / `existing-platform-user-assignment` / `static-role-permission` / `query-param-context`,输出权限矩阵后再实现。
13
14
  - 应用角色只读查询组织账号时声明 `app:organization:read`;创建、修改账号/部门或重置密码时声明 `app:organization:manage`。创建角色、分配成员或维护权限组也必须在角色资源的 `apiPermissionCodes` 声明对应的 `app:role:manage`、`app:page-permission-group:manage`、`app:form-permission-group:manage`。
14
15
  - 默认用户界面保持克制:左侧应用导航、顶部账号信息、首页内容区域。
@@ -8,7 +8,9 @@
8
8
 
9
9
  **所有"发布 / 上线 / 部署 / publish / deploy / ship / release"请求,唯一正确入口是 `openxiangda workspace publish --profile <name>`,不要直接 `pnpm publish:all`。**
10
10
 
11
- **架构类需求默认只规划、不实现。** 新应用、复杂页面、登录注册、公开访问、权限数据范围、流程自动化、连接器/通知等需求,先 `openxiangda doctor --json` + `openxiangda design gates --topic <code> --json`,输出设计并等待用户确认;确认前只允许读取、快照、dry-run、提问和写设计文档,不允许改源码、写平台、发布、部署或发送通知。
11
+ **架构类需求先过设计门。** 新应用、复杂页面、登录注册、公开访问、权限数据范围、流程自动化、连接器/通知等需求,先 `openxiangda doctor --json` + `openxiangda design gates --topic <code> --json`。只有仍存在会改变实现方向的业务、安全或数据选择时才输出设计并等待确认;用户已经给出具体需求与验收标准时,直接记录结构化 SDD 范围并实现,不再写长篇设计或重复确认。
12
+
13
+ 共享应用仓库把规范 `main`/`master` 检出专用于集成和发布;每个开发任务从最新远端主线使用独立 Git worktree/branch,禁止为了发布 stash/restore 或覆盖其他任务文件。调查默认只做一次完整 CodeGraph 查询,明确缺失时最多补一次精确查询;普通任务只加载一个领域技能。等待状态未变化时使用 `openxiangda task status --watch`,不要重复输出相同进度。
12
14
 
13
15
  **按风险分级治理。** 只读/文档/测试为 L0;纯文案样式或单一既有资源绑定等窄小可逆改动为 L1,可用受限的 `openxiangda sdd quick` 记录精确范围;表单结构、业务 Function、Automation/Workflow、权限、登录/公开访问、数据写入和 runtime/config 为 L2;不可逆、生产迁移或应用级扩权为 L3。L2/L3 必须挂完整 SDD,并由 `coverage.json` 把需求与场景映射到精确的文件、表单、页面及工程资源范围。
14
16