openxiangda 1.0.206 → 1.0.207

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
@@ -208,6 +208,8 @@ Page repair publishing is staged by default. It first freezes `pages/snapshot`,
208
208
 
209
209
  Use `openxiangda release app-capture` to read the platform's transactionally consistent whole-app manifest. For a normal multi-resource release, stage the changed Runtime/Page/Backend/Form child releases; commands carrying the same `--change` atomically accumulate their canonical immutable entries in `.openxiangda/releases/<change>/staged-resources.json`. Then run `app-finalize --change <change> --staged-resources-json <JSON|file>`. The CLI overlays Runtime/Page/Backend by singleton kind and Form by `formUuid`, preserves every unmodified active child from the read-only capture, and sends the complete frozen manifest through `prepare -> verify -> activate` with `activateStagedChildren=true`. Runtime staging never rewrites local active Runtime state, and a malformed staged response fails closed. The child heads and App head therefore switch in one database transaction (`atomic_staged_children_v1`); any parent, revision, asset, or hash drift stops with zero root activation and is never refreshed or retried. An aborted Form Release is never an idempotent success: rerunning the same scoped form publish removes its aborted staged index and creates a new immutable attempt automatically, after which the whole App is retried atomically. Never work around an App failure by sequentially activating forms. `app-prepare` accepts the same overlay for a manual reviewed flow, and `app-activate <releaseId> --activate-staged-children` performs the explicit atomic activation. Calling `app-finalize` without an overlay remains a compatibility-only retrospective aggregation of already-active children. `app-rollback <releaseId> --change <change> --reason "..."` prepares the audited rollback manifest.
210
210
 
211
+ If exact FormRelease children were already staged for the same change before a new release baseline or lease is acquired, the CLI reuses them only after checking the server's immutable, inactive, non-aborted release, exact app/form identity and content hash, frozen schema/formType, finalized resources, parent/base revision, and current Form head. Verified children are rebound to the newly owned baseline/session; local `schemaSyncedAt` is not treated as release evidence. Missing or conflicting evidence still fails closed, and neither direct schema synchronization nor early Form activation is required.
212
+
211
213
  For small live fixes, diagnosis, or AI command discovery, use first-class resource commands. They all accept `--profile`, `--app-type`, `--json`, `--json-file`, `--dry-run`, and write commands can use `--write-manifest` to avoid repo/platform drift:
212
214
 
213
215
  ```bash
package/lib/cli.js CHANGED
@@ -3834,7 +3834,10 @@ async function release(args) {
3834
3834
  );
3835
3835
  }
3836
3836
  const pendingBaseline = getStoredChangeBaseline(target);
3837
- const newReleaseLifecycle = !stored && !pendingBaseline;
3837
+ const newReleaseLifecycle = !existing && !pendingBaseline;
3838
+ const reusableFormCandidates = newReleaseLifecycle
3839
+ ? reusableStagedFormReleaseCandidates(target, changeId)
3840
+ : [];
3838
3841
  const clientSessionId =
3839
3842
  existing?.clientSessionId ||
3840
3843
  (pendingBaseline?.changeId === changeId &&
@@ -3866,6 +3869,7 @@ async function release(args) {
3866
3869
  let frozenCapture = null;
3867
3870
  let releaseSessionFile = null;
3868
3871
  let stagedResourcesFile = null;
3872
+ let reusedStagedFormReleases = [];
3869
3873
  try {
3870
3874
  if (flags['freeze-app-capture']) {
3871
3875
  frozenCapture = await fetchAppReleaseCapture(config, target, flags);
@@ -3877,9 +3881,16 @@ async function release(args) {
3877
3881
  frozenCapture
3878
3882
  );
3879
3883
  }
3880
- stagedResourcesFile = newReleaseLifecycle
3881
- ? initializeStagedAppReleaseResources(target, changeId)
3882
- : null;
3884
+ if (newReleaseLifecycle) {
3885
+ const initialized = await initializeNewReleaseStagedResources(
3886
+ config,
3887
+ target,
3888
+ changeId,
3889
+ reusableFormCandidates
3890
+ );
3891
+ stagedResourcesFile = initialized.file;
3892
+ reusedStagedFormReleases = initialized.reused;
3893
+ }
3883
3894
  } catch (error) {
3884
3895
  if (newReleaseLifecycle) {
3885
3896
  try {
@@ -3914,6 +3925,9 @@ async function release(args) {
3914
3925
  }
3915
3926
  : {}),
3916
3927
  ...(stagedResourcesFile ? { stagedResourcesFile } : {}),
3928
+ ...(reusedStagedFormReleases.length > 0
3929
+ ? { reusedStagedFormReleases }
3930
+ : {}),
3917
3931
  };
3918
3932
  if (flags.json) return writeJson(result);
3919
3933
  print(
@@ -4893,14 +4907,22 @@ function writeStagedResourcesFiles(
4893
4907
  return path.relative(process.cwd(), file).replace(/\\/g, '/');
4894
4908
  }
4895
4909
 
4896
- function initializeStagedAppReleaseResources(target, changeId) {
4910
+ function initializeStagedAppReleaseResources(
4911
+ target,
4912
+ changeId,
4913
+ resources = [],
4914
+ metadata = {}
4915
+ ) {
4897
4916
  const context = currentStagedResourcesContext(target, changeId);
4898
4917
  return writeStagedResourcesFiles(
4899
4918
  changeId,
4900
- [],
4919
+ Array.isArray(resources) && resources.length === 0
4920
+ ? []
4921
+ : normalizeStagedAppReleaseResources(resources),
4901
4922
  {
4902
4923
  ...context,
4903
4924
  initializedAt: new Date().toISOString(),
4925
+ ...metadata,
4904
4926
  },
4905
4927
  target.deploymentId
4906
4928
  );
@@ -5083,6 +5105,278 @@ function stagedFormCode(target, resource) {
5083
5105
  return formUuid;
5084
5106
  }
5085
5107
 
5108
+ function reusableStagedFormReleaseCandidates(target, changeId) {
5109
+ const normalizedChangeId = String(changeId || '').trim();
5110
+ if (!normalizedChangeId) return [];
5111
+ const file = stagedResourcesFileForChange(
5112
+ normalizedChangeId,
5113
+ target.deploymentId
5114
+ );
5115
+ if (!fs.existsSync(file)) return [];
5116
+ const contextFile = stagedResourcesContextFileForChange(
5117
+ normalizedChangeId,
5118
+ target.deploymentId
5119
+ );
5120
+ if (!fs.existsSync(contextFile)) {
5121
+ fail(
5122
+ `FORM_RELEASE_REUSE_CONTEXT_REQUIRED: change ${normalizedChangeId} 的 staged FormRelease 缺少 context,拒绝重挂接`
5123
+ );
5124
+ }
5125
+ const context = JSON.parse(fs.readFileSync(contextFile, 'utf8'));
5126
+ const expected = {
5127
+ contractVersion: 'staged_resources_context_v1',
5128
+ appType: target.appType,
5129
+ profile: target.profileName,
5130
+ changeId: normalizedChangeId,
5131
+ deploymentId: target.deploymentId || null,
5132
+ };
5133
+ for (const [field, value] of Object.entries(expected)) {
5134
+ if ((context?.[field] ?? null) !== (value ?? null)) {
5135
+ fail(
5136
+ `FORM_RELEASE_REUSE_CONTEXT_MISMATCH: ${field} 不属于当前 app/profile/change/deployment,拒绝重挂接 staged FormRelease`
5137
+ );
5138
+ }
5139
+ }
5140
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
5141
+ const resources = Array.isArray(parsed) && parsed.length === 0
5142
+ ? []
5143
+ : normalizeStagedAppReleaseResources(parsed);
5144
+ let scopedForms = null;
5145
+ try {
5146
+ scopedForms = new Set(
5147
+ getSddChangeScope({
5148
+ cwd: process.cwd(),
5149
+ configText: readWorkspaceConfigText(),
5150
+ changeId: normalizedChangeId,
5151
+ }).targets.forms || []
5152
+ );
5153
+ } catch (error) {
5154
+ // A direct release may use --change without a local SDD record. The
5155
+ // exact local context still supplies the app/profile/change scope, while
5156
+ // every candidate is independently verified against immutable server data.
5157
+ if (!/SDD 未初始化|change 不存在|ENOENT/i.test(String(error?.message || ''))) {
5158
+ throw error;
5159
+ }
5160
+ }
5161
+ return resources
5162
+ .filter(resource => resource.kind === 'FormRelease')
5163
+ .map(resource => {
5164
+ const formCode = stagedFormCode(target, resource);
5165
+ if (
5166
+ scopedForms &&
5167
+ (!formCode || !scopedForms.has(formCode))
5168
+ ) {
5169
+ fail(
5170
+ `FORM_RELEASE_REUSE_SCOPE_MISMATCH: ${formCode || resource.identity?.formUuid || '(unknown)'} 不在 change ${normalizedChangeId} 的精确 Form 范围内`
5171
+ );
5172
+ }
5173
+ return { resource, formCode };
5174
+ });
5175
+ }
5176
+
5177
+ function nullableReleaseId(value) {
5178
+ const normalized = String(value || '').trim();
5179
+ return normalized || null;
5180
+ }
5181
+
5182
+ async function verifyReusableStagedFormRelease(
5183
+ config,
5184
+ target,
5185
+ candidate
5186
+ ) {
5187
+ const resource = candidate.resource;
5188
+ const formCode = String(candidate.formCode || '').trim();
5189
+ const releaseId = String(resource.identity?.releaseId || '').trim();
5190
+ const formUuid = String(resource.identity?.formUuid || '').trim();
5191
+ const boundFormUuid = String(
5192
+ target.bound.resources?.forms?.[formCode]?.formUuid ||
5193
+ target.bound.resources?.formSettings?.[formCode]?.formUuid ||
5194
+ ''
5195
+ ).trim();
5196
+ if (!formCode || !boundFormUuid || boundFormUuid !== formUuid) {
5197
+ fail(
5198
+ `FORM_RELEASE_REUSE_BINDING_MISMATCH: ${formCode || formUuid || '(unknown)'} 的本地 binding 与 staged FormRelease identity 不一致`
5199
+ );
5200
+ }
5201
+ const basePath = `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/forms/${encodeURIComponent(formUuid)}`;
5202
+ const [detail, head] = await Promise.all([
5203
+ requestWithAuth(
5204
+ config,
5205
+ target.profileName,
5206
+ `${basePath}/releases/${encodeURIComponent(releaseId)}`
5207
+ ),
5208
+ requestWithAuth(
5209
+ config,
5210
+ target.profileName,
5211
+ `${basePath}/releases/head`
5212
+ ),
5213
+ ]);
5214
+ if (
5215
+ detail?.immutable !== true ||
5216
+ detail?.active === true ||
5217
+ detail?.aborted === true ||
5218
+ (Array.isArray(detail?.journal) ? detail.journal : []).some(
5219
+ entry => entry?.action === 'abort'
5220
+ )
5221
+ ) {
5222
+ fail(
5223
+ `FORM_RELEASE_REUSE_STATE_INVALID: ${formCode} 的 FormRelease ${releaseId} 必须 immutable=true、active=false、aborted=false`
5224
+ );
5225
+ }
5226
+ if (
5227
+ String(detail?.id || '').trim() !== releaseId ||
5228
+ String(detail?.formUuid || '').trim() !== formUuid
5229
+ ) {
5230
+ fail(
5231
+ `FORM_RELEASE_REUSE_IDENTITY_MISMATCH: ${formCode} 的 releaseId/formUuid 与服务端冻结证据不一致`
5232
+ );
5233
+ }
5234
+ const contentHash = String(detail?.contentHash || '')
5235
+ .trim()
5236
+ .toLowerCase();
5237
+ if (
5238
+ !/^[a-f0-9]{64}$/.test(contentHash) ||
5239
+ contentHash !== String(resource.hash || '').trim().toLowerCase()
5240
+ ) {
5241
+ fail(
5242
+ `FORM_RELEASE_REUSE_HASH_MISMATCH: ${formCode} 的 staged hash 与 immutable FormRelease 不一致`
5243
+ );
5244
+ }
5245
+ const parentReleaseId = nullableReleaseId(detail?.parentReleaseId);
5246
+ const baseRevision = Number(detail?.baseRevision);
5247
+ if (
5248
+ nullableReleaseId(resource.revision?.parentReleaseId) !==
5249
+ parentReleaseId ||
5250
+ Number(resource.revision?.baseRevision) !== baseRevision
5251
+ ) {
5252
+ fail(
5253
+ `FORM_RELEASE_REUSE_REVISION_MISMATCH: ${formCode} 的 staged parent/baseRevision 与 immutable FormRelease 不一致`
5254
+ );
5255
+ }
5256
+ const activeHead = head?.activeFormReleaseHead || {};
5257
+ const currentRevision = Number(head?.revision ?? activeHead.revision);
5258
+ if (
5259
+ nullableReleaseId(activeHead.releaseId) !== parentReleaseId ||
5260
+ !Number.isSafeInteger(currentRevision) ||
5261
+ currentRevision !== baseRevision
5262
+ ) {
5263
+ fail(
5264
+ `FORM_RELEASE_REUSE_PARENT_CONFLICT: ${formCode} 的 active head/revision 已变化,拒绝重挂接 staged FormRelease`
5265
+ );
5266
+ }
5267
+ if (
5268
+ !Array.isArray(detail?.resources) ||
5269
+ detail.resources.length === 0 ||
5270
+ detail.resources.some(item => !item?.finalizedAt)
5271
+ ) {
5272
+ fail(
5273
+ `FORM_RELEASE_REUSE_RESOURCES_INVALID: ${formCode} 的 FormRelease 资源尚未全部 finalized`
5274
+ );
5275
+ }
5276
+ const frozenForm = detail?.snapshotJson?.form;
5277
+ if (!frozenForm || frozenForm.schema === undefined || !frozenForm.formType) {
5278
+ fail(
5279
+ `FORM_RELEASE_REUSE_SCHEMA_MISSING: ${formCode} 的 FormRelease 缺少冻结 schema/formType`
5280
+ );
5281
+ }
5282
+ let frozenSchema = frozenForm.schema;
5283
+ if (typeof frozenSchema === 'string') {
5284
+ try {
5285
+ frozenSchema = JSON.parse(frozenSchema);
5286
+ } catch {
5287
+ fail(
5288
+ `FORM_RELEASE_REUSE_SCHEMA_INVALID: ${formCode} 的 FormRelease 冻结 schema 不是合法 JSON`
5289
+ );
5290
+ }
5291
+ }
5292
+ if (!frozenSchema || typeof frozenSchema !== 'object') {
5293
+ fail(
5294
+ `FORM_RELEASE_REUSE_SCHEMA_INVALID: ${formCode} 的 FormRelease 冻结 schema 无效`
5295
+ );
5296
+ }
5297
+ return {
5298
+ resource: {
5299
+ kind: 'FormRelease',
5300
+ identity: { releaseId, formUuid },
5301
+ action: resource.action || 'update',
5302
+ hash: contentHash,
5303
+ revision: { parentReleaseId, baseRevision },
5304
+ metadata: {
5305
+ ...(resource.metadata || {}),
5306
+ formCode,
5307
+ releaseStatus: 'staged',
5308
+ },
5309
+ },
5310
+ formCode,
5311
+ formUuid,
5312
+ releaseId,
5313
+ contentHash,
5314
+ baseRevision,
5315
+ parentReleaseId,
5316
+ formSnapshot: {
5317
+ ...clonePlainJson(frozenForm),
5318
+ schema: clonePlainJson(frozenSchema),
5319
+ },
5320
+ formType: frozenForm.formType,
5321
+ };
5322
+ }
5323
+
5324
+ async function resolveReusableStagedFormReleaseOverlays(
5325
+ config,
5326
+ target,
5327
+ changeId,
5328
+ options = {}
5329
+ ) {
5330
+ const requested = new Set(
5331
+ (options.formCodes || [])
5332
+ .map(value => String(value || '').trim())
5333
+ .filter(Boolean)
5334
+ );
5335
+ const candidates = reusableStagedFormReleaseCandidates(target, changeId)
5336
+ .filter(candidate => requested.size === 0 || requested.has(candidate.formCode));
5337
+ const verified = [];
5338
+ for (const candidate of candidates) {
5339
+ verified.push(
5340
+ await verifyReusableStagedFormRelease(config, target, candidate)
5341
+ );
5342
+ }
5343
+ return new Map(verified.map(item => [item.formCode, item]));
5344
+ }
5345
+
5346
+ async function initializeNewReleaseStagedResources(
5347
+ config,
5348
+ target,
5349
+ changeId,
5350
+ candidates
5351
+ ) {
5352
+ const verified = [];
5353
+ for (const candidate of candidates || []) {
5354
+ verified.push(
5355
+ await verifyReusableStagedFormRelease(config, target, candidate)
5356
+ );
5357
+ }
5358
+ const reused = verified.map(item => ({
5359
+ formCode: item.formCode,
5360
+ formUuid: item.formUuid,
5361
+ releaseId: item.releaseId,
5362
+ contentHash: item.contentHash,
5363
+ baseRevision: item.baseRevision,
5364
+ parentReleaseId: item.parentReleaseId,
5365
+ }));
5366
+ const file = initializeStagedAppReleaseResources(
5367
+ target,
5368
+ changeId,
5369
+ verified.map(item => item.resource),
5370
+ reused.length > 0
5371
+ ? {
5372
+ reusedStagedFormReleases: reused,
5373
+ reusedAt: new Date().toISOString(),
5374
+ }
5375
+ : {}
5376
+ );
5377
+ return { file, reused };
5378
+ }
5379
+
5086
5380
  function assertStagedAppReleaseScope(target, flags, resources) {
5087
5381
  const changeId = readStringFlag(flags, 'change');
5088
5382
  if (!changeId) {
@@ -6083,6 +6377,15 @@ async function resolvePublishLeaseForWrite(config, target, flags = {}) {
6083
6377
  const explicitLeaseId = readStringFlag(flags, 'publish-lease-id');
6084
6378
  const changeId = readStringFlag(flags, 'change');
6085
6379
  const storedLease = getStoredPublishLease(target);
6380
+ const baselineAtStart = changeId
6381
+ ? getStoredChangeBaseline(target)
6382
+ : null;
6383
+ const newReleaseLifecycle = Boolean(
6384
+ changeId && !getUsableStoredPublishLease(target) && !baselineAtStart
6385
+ );
6386
+ const reusableFormCandidates = newReleaseLifecycle
6387
+ ? reusableStagedFormReleaseCandidates(target, changeId)
6388
+ : [];
6086
6389
  let acquiredByCommand = false;
6087
6390
  assertOrClaimWorktreeOwner({
6088
6391
  cwd: process.cwd(),
@@ -6152,7 +6455,41 @@ async function resolvePublishLeaseForWrite(config, target, flags = {}) {
6152
6455
  clearRejectedPendingBaseline(target, baseline, error);
6153
6456
  throw error;
6154
6457
  }
6155
- finalizeReleaseBaseline(target, baseline);
6458
+ const finalizedBaseline = finalizeReleaseBaseline(target, baseline);
6459
+ if (newReleaseLifecycle) {
6460
+ try {
6461
+ await initializeNewReleaseStagedResources(
6462
+ config,
6463
+ target,
6464
+ changeId,
6465
+ reusableFormCandidates
6466
+ );
6467
+ } catch (error) {
6468
+ try {
6469
+ await requestWithAuth(
6470
+ config,
6471
+ target.profileName,
6472
+ publishLeaseApiPath(
6473
+ target,
6474
+ `/${encodeURIComponent(lease.leaseId)}/release`
6475
+ ),
6476
+ {
6477
+ method: 'POST',
6478
+ body: { completion: 'local-prepare-failed' },
6479
+ }
6480
+ );
6481
+ } catch {
6482
+ // Keep the staged child verification failure as the primary error.
6483
+ }
6484
+ clearPublishLease(target, lease.leaseId);
6485
+ clearChangeBaseline(
6486
+ target,
6487
+ finalizedBaseline?.baselineId || finalizedBaseline?.id
6488
+ );
6489
+ releaseWorktreeOwner({ cwd: process.cwd() });
6490
+ throw error;
6491
+ }
6492
+ }
6156
6493
  }
6157
6494
  if (!lease) return null;
6158
6495
  if (changeId && lease.changeId !== changeId) {
@@ -12635,9 +12972,34 @@ async function resource(args) {
12635
12972
 
12636
12973
  const validation = validateWorkspaceResources(manifest);
12637
12974
  let target = null;
12975
+ let stagedFormBindingOverlays = new Map();
12638
12976
  if (!isManifestEmpty(manifest)) {
12639
12977
  target = getWorkspaceTarget(config, profileName, flags);
12640
- validateWorkspaceResourceBindings(manifest, target.bound, validation);
12978
+ const workflowFormCodes = unique(
12979
+ (manifest.workflows || [])
12980
+ .map(item => item.formCode || item.form)
12981
+ .filter(Boolean)
12982
+ );
12983
+ const stagedChangeId =
12984
+ readStringFlag(flags, 'change') ||
12985
+ getStoredChangeBaseline(target, { access: 'reconciliation-read' })
12986
+ ?.changeId ||
12987
+ '';
12988
+ if (workflowFormCodes.length > 0 && stagedChangeId) {
12989
+ stagedFormBindingOverlays =
12990
+ await resolveReusableStagedFormReleaseOverlays(
12991
+ config,
12992
+ target,
12993
+ stagedChangeId,
12994
+ { formCodes: workflowFormCodes }
12995
+ );
12996
+ }
12997
+ validateWorkspaceResourceBindings(
12998
+ manifest,
12999
+ target.bound,
13000
+ validation,
13001
+ stagedFormBindingOverlays
13002
+ );
12641
13003
  }
12642
13004
  await validateCompiledWorkflowResources(manifest, validation);
12643
13005
  if (subcommand === 'validate') {
@@ -16123,7 +16485,12 @@ function validateWorkspaceResources(manifest) {
16123
16485
  };
16124
16486
  }
16125
16487
 
16126
- function validateWorkspaceResourceBindings(manifest, bound, validation) {
16488
+ function validateWorkspaceResourceBindings(
16489
+ manifest,
16490
+ bound,
16491
+ validation,
16492
+ stagedFormBindingOverlays = new Map()
16493
+ ) {
16127
16494
  for (const item of manifest.workflows || []) {
16128
16495
  if (!item.formCode && !item.form) continue;
16129
16496
  const formCode = item.formCode || item.form;
@@ -16136,14 +16503,25 @@ function validateWorkspaceResourceBindings(manifest, bound, validation) {
16136
16503
  continue;
16137
16504
  }
16138
16505
  const localSchemaPath = path.join(process.cwd(), 'src', 'forms', formCode, 'schema.ts');
16139
- if (fs.existsSync(localSchemaPath) && !formBinding.schemaSyncedAt) {
16506
+ const stagedOverlay = stagedFormBindingOverlays.get(formCode);
16507
+ if (
16508
+ fs.existsSync(localSchemaPath) &&
16509
+ !formBinding.schemaSyncedAt &&
16510
+ !stagedOverlay
16511
+ ) {
16140
16512
  validation.errors.push(
16141
16513
  `${resourceLabel('workflow', item)}: formCode ${formCode} 已绑定但本地 schema 尚未同步。请先运行 openxiangda workspace publish --profile <name> --form ${formCode}`
16142
16514
  );
16143
16515
  }
16144
- if (formBinding.formType && formBinding.formType !== 'process') {
16516
+ if (stagedOverlay) {
16517
+ validation.warnings.push(
16518
+ `${resourceLabel('workflow', item)}: formCode ${formCode} 使用已验证 staged FormRelease ${stagedOverlay.releaseId} 的冻结 schema/formType`
16519
+ );
16520
+ }
16521
+ const effectiveFormType = stagedOverlay?.formType || formBinding.formType;
16522
+ if (effectiveFormType && effectiveFormType !== 'process') {
16145
16523
  validation.errors.push(
16146
- `${resourceLabel('workflow', item)}: formCode ${formCode} 当前 formType=${formBinding.formType},流程表单需要 process`
16524
+ `${resourceLabel('workflow', item)}: formCode ${formCode} 当前 formType=${effectiveFormType},流程表单需要 process`
16147
16525
  );
16148
16526
  }
16149
16527
  }
@@ -17397,8 +17775,12 @@ async function buildResourcePlan(
17397
17775
  await prepareManifestJsCodeBundlesForPlan(manifest);
17398
17776
  const stagedLocalFormSnapshots =
17399
17777
  await resolveStagedLocalFormContractSnapshots(
17778
+ config,
17400
17779
  target,
17401
- options.flags || {}
17780
+ options.flags || {},
17781
+ (manifest.workflows || [])
17782
+ .map(item => item.formCode || item.form)
17783
+ .filter(Boolean)
17402
17784
  );
17403
17785
  const [existing, formFieldContracts] = await Promise.all([
17404
17786
  fetchExistingResourceMaps(config, target, manifest),
@@ -26855,32 +27237,38 @@ async function runWorkspaceChildCommand(command, args, options = {}) {
26855
27237
  }
26856
27238
 
26857
27239
  async function resolveStagedLocalFormContractSnapshots(
27240
+ config,
26858
27241
  target,
26859
- flags = {}
27242
+ flags = {},
27243
+ implicitFormCodes = []
26860
27244
  ) {
26861
- const formCodes = unique(
27245
+ const explicitFormCodes = unique(
26862
27246
  String(flags['staged-form-contracts'] || '')
26863
27247
  .split(',')
26864
27248
  .map(value => value.trim())
26865
27249
  .filter(Boolean)
26866
27250
  ).sort();
27251
+ const formCodes = unique([
27252
+ ...explicitFormCodes,
27253
+ ...(implicitFormCodes || [])
27254
+ .map(value => String(value || '').trim())
27255
+ .filter(Boolean),
27256
+ ]).sort();
26867
27257
  if (formCodes.length === 0) return new Map();
26868
27258
  const changeId = readStringFlag(flags, 'change');
26869
27259
  if (!changeId) {
27260
+ if (explicitFormCodes.length === 0) return new Map();
26870
27261
  fail(
26871
27262
  'FORM_FIELD_STAGED_CONTRACT_CHANGE_REQUIRED: --staged-form-contracts 必须与 --change 一起使用'
26872
27263
  );
26873
27264
  }
26874
- const context = assertStagedResourcesContext(target, changeId);
26875
- const file = stagedResourcesFileForChange(
26876
- changeId,
26877
- target.deploymentId
26878
- );
26879
- const resources = fs.existsSync(file)
26880
- ? normalizeStagedAppReleaseResources(
26881
- JSON.parse(fs.readFileSync(file, 'utf8'))
26882
- )
26883
- : [];
27265
+ const stagedOverlays =
27266
+ await resolveReusableStagedFormReleaseOverlays(
27267
+ config,
27268
+ target,
27269
+ changeId,
27270
+ { formCodes }
27271
+ );
26884
27272
  const snapshots = new Map();
26885
27273
  for (const formCode of formCodes) {
26886
27274
  const formUuid = resolveManifestFormUuid(
@@ -26888,29 +27276,15 @@ async function resolveStagedLocalFormContractSnapshots(
26888
27276
  { formCode },
26889
27277
  { fallbackToCode: false }
26890
27278
  );
26891
- const staged = resources.find(
26892
- resource =>
26893
- resource.kind === 'FormRelease' &&
26894
- String(resource.identity?.formUuid || '').trim() === formUuid
26895
- );
26896
- if (!staged) {
27279
+ const staged = stagedOverlays.get(formCode);
27280
+ if (!staged || staged.formUuid !== formUuid) {
26897
27281
  fail(
26898
27282
  `FORM_FIELD_STAGED_CONTRACT_REQUIRED: ${formCode} 尚未在 change ${changeId} 的当前 deployment 中获得 verified FormRelease`
26899
27283
  );
26900
27284
  }
26901
- const localSchema = await loadLocalFormSchema(
26902
- process.cwd(),
26903
- formCode
26904
- );
26905
- if (!localSchema) {
26906
- fail(
26907
- `FORM_FIELD_STAGED_CONTRACT_SCHEMA_MISSING: ${formCode} 缺少本地 schema`
26908
- );
26909
- }
26910
- const schema = JSON.parse(localSchema.schema);
26911
27285
  snapshots.set(
26912
27286
  formUuid,
26913
- collectFormSnapshotFieldIds({ form: { schema } })
27287
+ collectFormSnapshotFieldIds({ form: staged.formSnapshot })
26914
27288
  );
26915
27289
  }
26916
27290
  return snapshots;
@@ -126,6 +126,8 @@ Because promotion begins from the already-pushed authoritative mainline, `releas
126
126
 
127
127
  For a whole-app release, keep changed Runtime/Page/Backend/Form children staged and run `openxiangda release app-finalize --change <change> --staged-resources-json <JSON|file> --profile <name>`. The JSON contains only changed immutable child entries; the CLI overlays Runtime/Page/Backend by singleton kind and FormRelease by `formUuid` onto one authoritative read-only capture, preserving every unmodified active child. It then performs `prepare -> verify -> activateStagedChildren=true`, switching child heads and the App head atomically (`atomic_staged_children_v1`, or `atomic_staged_children_v2` when a Backend Release v2 child is present). Never refresh or retry after a conflict. The flow requires the owned stored lease/change baseline and carries the same client session and Git lineage on every write. Omitting the overlay is compatibility-only retrospective aggregation of already-active children.
128
128
 
129
+ When the same change already has staged FormRelease children before a fresh baseline/lease is acquired, keep them staged. The CLI may rebind each child into the new session only after server verification of immutable/inactive/non-aborted state, exact app/form identity and content hash, frozen schema/formType, finalized resources, parent/base revision, and current Form head. `schemaSyncedAt` is local cache metadata, not release evidence. Do not direct-publish or activate a Form to bypass Workflow validation; missing or conflicting staged evidence must fail closed.
130
+
129
131
  When a Function or Automation is selected because its TypeScript source changed, publishing is source-only by default. Backend Release v2 accepts source-backed create, source-free declarative Automation manifest create, source-only update, and manifest replacement update in one immutable child and one database transaction, so a stale member produces zero resource writes and noops do not advance versions/timestamps. A new Automation with a complete `definitionJson.version="v3"` and no `sourceFile` automatically uses manifest create without `--replace-manifest`; an incomplete definition still fails closed. `--stage-only` is fail-closed: every selected mutation must enter that child, and a missing/incompatible Backend Release API never falls back to direct writes. Online bindings, input/output contracts, metadata, trigger/view configuration, and enabled/published state remain unchanged unless exact `--replace-manifest --reason "..."` authority was provided for an existing resource.
130
132
 
131
133
  An App Function may declare metadata-only top-level `secretRefs: [{ name, required }]` only with `function_v2` + `trusted_node_v2`; source resolves values with `await ctx.secrets.get(name)` and uses `ctx.utils.http` for controlled public HTTPS. Create/rotate values through hidden TTY or `openxiangda secret ... --value-stdin --change <change> --profile <name>`. Never put values in arguments, files, manifests, state, plans, logs, errors, or chat. Secret bindings require `backend_release_v2` and whole-app `atomic_staged_children_v2`; a missing capability is fail-closed and never uses the legacy source PATCH. For whole-app activation use exact-scope `resource publish <type> --only <code> --stage-only`, then pass the returned verified `stagedResource` to `release app-finalize`; an active Backend Release is never labeled staged.
@@ -86,6 +86,8 @@ openxiangda resource publish form-setting --only <formCode> --change <change> --
86
86
 
87
87
  The Form resource bundle carries schema, settings, indexes, data-management, runtime-write, public-access configuration, and changed form permission groups under one CAS parent. It returns a canonical staged FormRelease and the CLI records it in the change-scoped staged-resources file. `workspace publish --form` is limited to an intentional first-time bootstrap or isolated repair outside a governed multi-resource release; it is not the normal release path.
88
88
 
89
+ An exact staged FormRelease remains usable across a fresh release baseline/lease for the same app/profile/change/deployment only after the CLI re-verifies its immutable, inactive, non-aborted server state, identity/hash, frozen schema/formType, finalized resources, parent/base revision, and current Form head. Workflow planning reads that frozen contract even when the live Form head is intentionally not activated and local `schemaSyncedAt` is absent. Never synthesize `schemaSyncedAt`, direct-publish the schema, or activate the Form early as a workaround; all conflicts fail closed.
90
+
89
91
  For Phase 6 React SPA workspaces, `app-workspace.config.ts` should declare
90
92
  `runtimeMode: "react-spa"`. In that mode, `workspace publish --form <code>` is
91
93
  schema-only by default: it creates/binds the form and syncs schema, but skips
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda",
3
- "version": "1.0.206",
3
+ "version": "1.0.207",
4
4
  "description": "OpenXiangda CLI, workspace build tools, runtime SDK, and form components.",
5
5
  "private": false,
6
6
  "bin": {
@@ -34,6 +34,7 @@ This is an OpenXiangda React SPA workspace. See [AGENTS.md](mdc:AGENTS.md) for f
34
34
  - `src/resources/**` is the resource source of truth; use `validate -> plan -> publish`.
35
35
  - Select logical resource codes with `--only`, or one code with `--code`; type-wide/app-wide release requires an approved dependency closure.
36
36
  - Source-triggered Function/Automation publishing uses server-side source-field PATCH and preserves online bindings, contracts, metadata, trigger/view configuration, and enabled/published state. A new source-free Automation with a complete `definitionJson.version="v3"` automatically uses Backend Release manifest create without `--replace-manifest`; replacing an existing whole manifest requires `--replace-manifest --reason "..."`. On `SOURCE_BASE_DIVERGED` or `RESOURCE_FIELD_CONFLICT`, reconcile, rebuild, and re-plan.
37
+ - A staged FormRelease for the same change may be rebound to a fresh baseline/session only after server verification of immutable/inactive/non-aborted state, identity/hash, frozen schema/formType, finalized resources, parent/base revision, and current Form head. `schemaSyncedAt` is not release evidence; never synthesize it, direct-publish the schema, or activate the Form early to bypass Workflow validation.
37
38
  - Managed `release ship --replace-manifest --reason "..."` requires the pair, reason length >= 8, and exact Backend selectors; production confirmation repeats the exact preproduction pair, with no forwarding to Form/Workflow/Runtime/config/all stages.
38
39
  - React routes live in `src/app/router.tsx`; frontend artifacts are deployed with `openxiangda runtime deploy`.
39
40
  - Use `AttachmentField` / `ImageField` for form-context previews, and `AttachmentPreviewList` / `ImagePreviewGrid` / `useFilePreview` for standalone custom pages. Do not fake form context, import internal preview implementations, or maintain local previewable-extension lists.
@@ -30,6 +30,7 @@ This is an OpenXiangda React SPA workspace. Read [AGENTS.md](AGENTS.md) for full
30
30
  - `src/resources/**` 是工程化资源来源,正式多资源变更走 `validate -> plan -> publish`。
31
31
  - 默认按逻辑资源 code 使用 `--only` 或单资源 `--code`;全类型/全应用发布必须由批准的依赖闭包明确覆盖。
32
32
  - Function/Automation 源码触发默认走服务端 source-field PATCH,保留线上 bindings/contracts/metadata/trigger/view/enabled/published state;无源码且 `definitionJson.version="v3"` 完整的新建 Automation 自动走 manifest create,只有替换已有整包 manifest 才必须加 `--replace-manifest --reason "..."`。
33
+ - 相同 change 的 staged FormRelease 只有经服务端重新核验 immutable/inactive/non-aborted、identity/hash、冻结 schema/formType、finalized 资源、parent/base revision 与当前 Form Head 后,才可重挂接新 baseline/session。`schemaSyncedAt` 不是发布证据;禁止伪造、直发 schema 或提前激活 Form 绕过 Workflow 校验。
33
34
  - 环境托管 `release ship --replace-manifest --reason "..."` 必须成对、reason 至少 8 字符且仅限精确 Backend selector;正式确认复用与预发完全相同的参数,不透传 Form/Workflow/Runtime/配置或全量步骤。
34
35
  - Promotion 必须持有 `release begin/end` 租约;`release begin` 只接受与权威远端 tip 完全一致的 clean main/master。feature branch 或未 push 主线在任何写入前失败。激活后直接运行 `integration-status` 和 `release end`,不再补做发布后合并。
35
36
  - 已有工作区通过 `environment attach` 接入环境组,旧资源映射只迁移到 appType 相同的预发 target,正式 target 必须为空。preproduction / production 分别拥有独立 appType、资源 ID、数据和副作用策略;禁止直接 `release publish`,禁止跨环境复制 ID,使用 `openxiangda studio` 查看状态。只有用户明确授权的投产前重分类可执行 `environment swap --reason "..." --confirm-production`;它不移动应用数据或 Release Head,且默认不放开副作用。
@@ -51,7 +51,7 @@ openxiangda studio
51
51
  openxiangda commands --json
52
52
  ```
53
53
 
54
- 模板已停用无范围的 `pnpm deploy` 聚合入口。日常变更必须使用 `resource plan|publish <type> --only <codes>`(单资源可用 `--code <code>`)。Form bundle、Backend Release 和 Runtime 都先暂存;CLI 会按 `--change` 自动聚合 `.openxiangda/releases/<change>/staged-resources.json`,最后只由一次 Root App finalize 原子激活。Form Release 一旦 abort 绝不能作为幂等结果复用;重新执行相同精确表单发布时,CLI 会淘汰旧 staged 索引,平台会创建新的不可变 attempt,再由 Root App 一次事务重试。禁止用顺序激活多张表单绕过失败。不要使用 `workspace publish --form`、单独 `runtime activate`、`pnpm publish:all`、`pnpm openxiangda:publish` 或 `lowcode-workspace publish-all`。
54
+ 模板已停用无范围的 `pnpm deploy` 聚合入口。日常变更必须使用 `resource plan|publish <type> --only <codes>`(单资源可用 `--code <code>`)。Form bundle、Backend Release 和 Runtime 都先暂存;CLI 会按 `--change` 自动聚合 `.openxiangda/releases/<change>/staged-resources.json`,最后只由一次 Root App finalize 原子激活。Form Release 一旦 abort 绝不能作为幂等结果复用;重新执行相同精确表单发布时,CLI 会淘汰旧 staged 索引,平台会创建新的不可变 attempt,再由 Root App 一次事务重试。相同 change 的既有 staged FormRelease 只有在 CLI 重新核验服务端不可变状态、identity/hash、冻结 schema/formType、finalized 资源、parent/base revision 和当前 Form Head 后,才可重挂接到新的 baseline/session;`schemaSyncedAt` 只是本地缓存元数据。禁止伪造它、提前激活 Form 或用顺序激活多张表单绕过失败。不要使用 `workspace publish --form`、单独 `runtime activate`、`pnpm publish:all`、`pnpm openxiangda:publish` 或 `lowcode-workspace publish-all`。
55
55
 
56
56
  工作区一旦通过 `environment init` 登记,或通过 `environment attach` 接入已有环境组,`release publish` 即不再是入口。日常使用两段式 `release ship`:第一次冻结不可变 candidate 并只部署到 preproduction,停止在 `awaiting_production_confirmation`;确认预发结果后,第二次命令提供 `--confirm-production`,把同一 candidate 晋级 production,不会重新构建。真实人工验收是默认建议,可用可选的 `--acceptance-note` 留痕;但用户明确授权的低风险或紧急发布不受僵硬审批门禁阻塞。两套环境的 appType、资源 ID、数据和副作用策略完全独立;旧单目标资源映射只允许迁移到 appType 相同的预发 target,正式 target 必须为空。仅在用户明确授权的投产前重分类中使用 `environment swap --reason "..." --confirm-production` 原子交换两个既有应用的环境角色;数据和 Release Head 不移动,副作用默认不放开。单独调整某个环境的副作用策略时,先运行 `environment policy update <kind|id> ... --dry-run` 查看差异,再按 CAS revision 写入;patch 只校验本次提交字段并原样保留未知历史字段,`--full-replace` 才按完整目标删除遗漏字段。不得借用 swap,正式写入另需 `--confirm-production`。`organizationWrites=explicit_capability_only` 仍强制 `app:organization:manage`。发布硬门禁只保留明确 scope/profile/target、权限、干净且已推送主线、不可变版本、CAS/租约与生产确认;文案和人工验收说明默认是建议,只有显式 strict 模式才阻断。`openxiangda studio` 提供仅本机访问的开发者页面,用于查看绑定、漂移、候选、部署、证据和下一安全动作。
57
57
 
@@ -30,6 +30,7 @@ This is a `sy-lowcode-app-workspace` managed by the `openxiangda` CLI. See [AGEN
30
30
  - Very small copy/style/binding changes may use `openxiangda sdd quick <change> ...`; quick mode is limited to an exact low-risk scope and does not add a redundant proposal/approval loop when the user already requested that exact edit.
31
31
  - SDD is streamlined by default: approval and exact structured scope are hard gates, while unfinished task/evidence/spec prose only warns. Use `strictDocumentation: true` only when prose must block.
32
32
  - Source-triggered Function/Automation publishing uses server-side source-field PATCH by default and preserves online bindings, contracts, metadata, trigger/view configuration, and enabled/published state. A new source-free Automation with a complete `definitionJson.version="v3"` automatically uses manifest create; replacing an existing whole manifest requires exact `--only/--code` plus `--replace-manifest --reason "..."`.
33
+ - A staged FormRelease for the same change may be rebound to a fresh baseline/session only after server verification of immutable/inactive/non-aborted state, identity/hash, frozen schema/formType, finalized resources, parent/base revision, and current Form head. `schemaSyncedAt` is not release evidence; never synthesize it, direct-publish the schema, or activate the Form early to bypass Workflow validation.
33
34
  - Managed `release ship --replace-manifest --reason "..."` requires the pair, reason length >= 8, and exact Backend selectors; production confirmation repeats the exact preproduction pair, with no forwarding to Form/Workflow/Runtime/config/all stages.
34
35
  - Before platform writes, run `release begin` only from clean main/master exactly equal to the authoritative remote tip. Feature branches and unpushed mainline commits fail before writes. After activation, run `integration-status` and `release end`; no post-release merge is needed.
35
36
  - Environment-managed workspaces never publish directly. Existing workspaces use `environment attach`; only an appType-matching legacy binding may seed preproduction and production starts empty. Run `release candidate`, `release deploy --environment preproduction`, `release test`, then promote that same candidate with `release promote --environment production --confirm-production`. Keep app/resource/data IDs isolated and inspect state with `openxiangda studio`. Only an explicitly authorized commissioning reclassification may use `environment swap --reason "..." --confirm-production`; it preserves app data/Release Heads and keeps side effects restricted by default.
@@ -30,6 +30,7 @@ This is a `sy-lowcode-app-workspace` managed by the `openxiangda` CLI. Read [AGE
30
30
  - 极小的文案、样式、绑定修正可使用 `openxiangda sdd quick <change> ...`,但必须限制在精确的低风险范围内;用户已明确要求该小改时不再重复 propose/approve。
31
31
  - SDD 默认 streamlined:approval 与结构化精确范围是硬门禁,未完成的 task/evidence/spec 文案只告警;只有 `strictDocumentation: true` 才阻断。
32
32
  - Function/Automation 源码触发默认走服务端 source-field PATCH,保留线上 bindings/contracts/metadata/trigger/view/enabled/published state;无源码且 `definitionJson.version="v3"` 完整的新建 Automation 自动走 manifest create,只有替换已有整包 manifest 才必须精确 `--only/--code` 并加 `--replace-manifest --reason "..."`。
33
+ - 相同 change 的 staged FormRelease 只有经服务端重新核验 immutable/inactive/non-aborted、identity/hash、冻结 schema/formType、finalized 资源、parent/base revision 与当前 Form Head 后,才可重挂接新 baseline/session。`schemaSyncedAt` 不是发布证据;禁止伪造、直发 schema 或提前激活 Form 绕过 Workflow 校验。
33
34
  - 环境托管 `release ship --replace-manifest --reason "..."` 必须成对、reason 至少 8 字符且仅限精确 Backend selector;正式确认复用与预发完全相同的参数,不透传 Form/Workflow/Runtime/配置或全量步骤。
34
35
  - 写入平台前只从与权威远端 tip 完全一致的 clean main/master 执行 `release begin`。feature branch 或未 push 主线在任何写入前失败;激活后直接运行 `integration-status` 和 `release end`,无需发布后再合并。
35
36
  - 环境托管工作区禁止直发:已有工作区先用 `environment attach` 接入,旧映射只能迁移到 appType 相同的预发 target,正式 target 必须为空。然后执行 `release candidate`、`release deploy --environment preproduction`、`release test`,最后将同一 candidate 用 `release promote --environment production --confirm-production` 晋级。两套环境的 appType、资源 ID 和数据不可互拷;用 `openxiangda studio` 查看状态。只有用户明确授权的投产前重分类可执行 `environment swap --reason "..." --confirm-production`;它不移动应用数据或 Release Head,且默认不放开副作用。
@@ -56,6 +56,7 @@
56
56
  - ✅ 正式多资源开发优先写 `src/resources/**` 后执行 `openxiangda resource validate|plan|publish <type> --only <codes>`;单资源可用 `--code <code>`。直接 CLI 写平台资源时先 `--dry-run`,需要避免漂移就加 `--write-manifest`。
57
57
  - ✅ `resource plan` 与 publish dry-run 严格只允许 GET/HEAD;遇到 `READ_ONLY_AUTH_REQUIRED` 时先执行 `openxiangda auth refresh --profile <name>` 或重新登录,不得在 plan 内自动 POST 刷新 token。
58
58
  - ✅ Function/Automation 使用 Backend Release v2;正式多资源发布用精确 `--only/--code` 加 `--stage-only` 暂存,同一 child 可混合源码 create、无 `sourceFile` 的完整 v3 声明式 Automation manifest create、source-only update 和显式 manifest replacement,再由 Root App finalize 原子激活。声明式 create 自动选路;替换已有资源的整包 manifest 才需要另加 `--replace-manifest --reason "..."`。
59
+ - ✅ 相同 change 已有 staged FormRelease 时,不要直发 schema、伪造 `schemaSyncedAt` 或提前激活 Form。CLI 只在重新核验服务端不可变状态、identity/hash、冻结 schema/formType、finalized 资源、parent/base revision 与当前 Form Head 后,才将 child 重挂接到新的 baseline/session;冲突继续失败关闭。
59
60
  - ✅ 未登记环境的旧工作区,正式 promotion 先聚合 mainline bundle 并 commit/push,再运行 `release publish --change <id> --profile <name>`。
60
61
  - ✅ 已通过 `environment init` 登记或 `environment attach` 接入的工作区默认使用两段式 `release ship`。第一次命令只冻结 candidate 并部署 preproduction,停止等待正式晋级确认;确认预发结果后,另一次命令提供 `--confirm-production`,即可晋级同一 candidate。人工验收是默认建议,可用可选的 `--acceptance-note` 留痕,但不是所有低风险或紧急发布的硬审批门禁。preproduction / production 的 appType、资源 ID、数据和副作用策略完全隔离,严禁跨环境复制 ID 或直接 `release publish`。旧单目标映射只允许按相同 appType 迁移到预发。仅在用户明确授权的投产前重分类中使用 `environment swap --reason "..." --confirm-production` 原子交换两个既有应用的环境角色;数据和 Release Head 不移动,副作用默认不放开。单环境副作用策略用 `environment policy update <kind|id> ... --dry-run` 预览后按 revision CAS 写入,禁止借用 swap;patch 只校验本次提交字段并原样保留未知历史字段,`--full-replace` 才按完整目标删除遗漏字段,正式写入需 `--confirm-production`,且 `organizationWrites=explicit_capability_only` 不绕过 `app:organization:manage`。发布硬门禁只保留明确 scope/profile/target、权限、干净且已推送主线、不可变版本、CAS/租约与生产确认;文案和人工验收说明默认是建议,只有显式 strict 模式才阻断。
61
62
  - ✅ 只有已审计目标早已进入权威主线、线上却由多次历史 lineage 组成且无法对应单一 Git 基线时,第一次 `release ship` 才可增加 `--adopt-online-baseline --adoption-reason "..."`;仅允许精确非删除 selectors,冻结 Head、change/lease、服务端 CAS、staged children 与单次 App finalize 仍是硬门禁。