openxiangda 1.0.206 → 1.0.208

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
@@ -202,12 +202,14 @@ openxiangda resource publish function --code customer_get --change <change> --pr
202
202
 
203
203
  Exact `--only` / `--code` selectors are applied before unrelated manifests, source dependencies, and JS_CODE targets are read or built. Shared/transitive dependencies of the selected targets remain in scope; omitting a selector intentionally preserves full-workspace validation and planning. Resource commands use the packaged canonical scoped builder for standard workspaces, so an older checked-in `scripts/build-js-code.mjs` does not need to be upgraded before the installed CLI gains this optimization; refresh the workspace template only when developers also need the same behavior from a manual `pnpm build-js-code` command.
204
204
 
205
- For source-only Function/Automation changes, the final command does not reconstruct whole definitions with client-side GET+PUT. `release begin` captures the Git/change baseline, one preflight covers every selected code, and Backend Release performs one `prepare -> verify -> activate` sequence for all eligible updates. Inspect history with `openxiangda release backend-head|backend-list|backend-detail`, compare it with `backend-diff`, or create an audited immutable rollback with `backend-rollback <releaseId> --change <change> --reason "..."`. Always merge/push the frozen SHA and run `release end` when promotion finishes. A lost or expired lease retains the pending-mainline evidence instead of silently clearing it.
205
+ For source-only Function/Automation changes, the final command does not reconstruct whole definitions with client-side GET+PUT. `release begin` captures the Git/change baseline, one preflight covers every selected code, and Backend Release performs one `prepare -> verify -> activate` sequence for all eligible updates. Repository identity is alias-aware but fail-closed: when a clone-derived primary ID differs from the frozen source base, the CLI uses the frozen canonical ID only if it is present in `releaseSourceRevision.repoAliases`; Backend, Workflow, and Root App Release writes all carry that same ID, while a disjoint identity set fails before prepare. Inspect history with `openxiangda release backend-head|backend-list|backend-detail`, compare it with `backend-diff`, or create an audited immutable rollback with `backend-rollback <releaseId> --change <change> --reason "..."`. Always merge/push the frozen SHA and run `release end` when promotion finishes. A lost or expired lease retains the pending-mainline evidence instead of silently clearing it.
206
206
 
207
207
  Page repair publishing is staged by default. It first freezes `pages/snapshot`, sends the active Page Release parent plus every page revision, and uses revision `0` only for a genuinely new page. Review with `openxiangda page head|releases|detail|diff`; activate an immutable complete release explicitly with `page activate <releaseId> --change <change>`. Historical activation requires `page rollback <releaseId> --rollback --change <change> --reason "..."`. Parent or revision conflicts are never refreshed or retried automatically.
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
@@ -247,6 +247,53 @@ function normalizeManagedReleaseSourceRevision(
247
247
  };
248
248
  }
249
249
 
250
+ function normalizeReleaseSourceRevisionForBaseline(
251
+ target,
252
+ sourceBase,
253
+ sourceRevision,
254
+ options = {}
255
+ ) {
256
+ const revision = normalizeManagedReleaseSourceRevision(
257
+ target,
258
+ sourceRevision,
259
+ options
260
+ );
261
+ const expectedRepositoryId = String(
262
+ sourceBase?.repo || sourceBase?.repositoryId || ''
263
+ )
264
+ .trim()
265
+ .toLowerCase();
266
+ if (!expectedRepositoryId) return revision;
267
+ const repositoryAliases = normalizeIdentityList([
268
+ revision?.repo,
269
+ revision?.repositoryId,
270
+ ...(Array.isArray(revision?.repoAliases) ? revision.repoAliases : []),
271
+ ]);
272
+ if (!repositoryAliases.includes(expectedRepositoryId)) {
273
+ const code =
274
+ String(options.errorCode || '').trim() ||
275
+ 'RELEASE_SOURCE_REPOSITORY_MISMATCH';
276
+ const error = new Error(
277
+ `${code}: 当前 Git 仓库与冻结 change baseline 仓库不一致`
278
+ );
279
+ error.code = code;
280
+ error.details = {
281
+ expectedRepositoryId,
282
+ repositoryAliases,
283
+ };
284
+ throw error;
285
+ }
286
+ return {
287
+ ...revision,
288
+ repo: expectedRepositoryId,
289
+ repositoryId: expectedRepositoryId,
290
+ repoAliases: normalizeIdentityList([
291
+ expectedRepositoryId,
292
+ ...repositoryAliases,
293
+ ]),
294
+ };
295
+ }
296
+
250
297
  function normalizeManagedChangeSourceBase(
251
298
  target,
252
299
  sourceBase,
@@ -597,6 +644,7 @@ module.exports = {
597
644
  normalizeEnvironmentKind,
598
645
  normalizeManagedChangeSourceBase,
599
646
  normalizeManagedReleaseSourceRevision,
647
+ normalizeReleaseSourceRevisionForBaseline,
600
648
  normalizeSideEffectPolicyInput,
601
649
  normalizeTargetName,
602
650
  hasStateResourceMappings,
package/lib/cli.js CHANGED
@@ -136,6 +136,7 @@ const {
136
136
  normalizeEnvironmentKind,
137
137
  normalizeManagedChangeSourceBase,
138
138
  normalizeManagedReleaseSourceRevision,
139
+ normalizeReleaseSourceRevisionForBaseline,
139
140
  readCandidate,
140
141
  readJsonInput,
141
142
  releaseExecutionPath,
@@ -3834,7 +3835,10 @@ async function release(args) {
3834
3835
  );
3835
3836
  }
3836
3837
  const pendingBaseline = getStoredChangeBaseline(target);
3837
- const newReleaseLifecycle = !stored && !pendingBaseline;
3838
+ const newReleaseLifecycle = !existing && !pendingBaseline;
3839
+ const reusableFormCandidates = newReleaseLifecycle
3840
+ ? reusableStagedFormReleaseCandidates(target, changeId)
3841
+ : [];
3838
3842
  const clientSessionId =
3839
3843
  existing?.clientSessionId ||
3840
3844
  (pendingBaseline?.changeId === changeId &&
@@ -3866,6 +3870,7 @@ async function release(args) {
3866
3870
  let frozenCapture = null;
3867
3871
  let releaseSessionFile = null;
3868
3872
  let stagedResourcesFile = null;
3873
+ let reusedStagedFormReleases = [];
3869
3874
  try {
3870
3875
  if (flags['freeze-app-capture']) {
3871
3876
  frozenCapture = await fetchAppReleaseCapture(config, target, flags);
@@ -3877,9 +3882,16 @@ async function release(args) {
3877
3882
  frozenCapture
3878
3883
  );
3879
3884
  }
3880
- stagedResourcesFile = newReleaseLifecycle
3881
- ? initializeStagedAppReleaseResources(target, changeId)
3882
- : null;
3885
+ if (newReleaseLifecycle) {
3886
+ const initialized = await initializeNewReleaseStagedResources(
3887
+ config,
3888
+ target,
3889
+ changeId,
3890
+ reusableFormCandidates
3891
+ );
3892
+ stagedResourcesFile = initialized.file;
3893
+ reusedStagedFormReleases = initialized.reused;
3894
+ }
3883
3895
  } catch (error) {
3884
3896
  if (newReleaseLifecycle) {
3885
3897
  try {
@@ -3914,6 +3926,9 @@ async function release(args) {
3914
3926
  }
3915
3927
  : {}),
3916
3928
  ...(stagedResourcesFile ? { stagedResourcesFile } : {}),
3929
+ ...(reusedStagedFormReleases.length > 0
3930
+ ? { reusedStagedFormReleases }
3931
+ : {}),
3917
3932
  };
3918
3933
  if (flags.json) return writeJson(result);
3919
3934
  print(
@@ -4893,14 +4908,22 @@ function writeStagedResourcesFiles(
4893
4908
  return path.relative(process.cwd(), file).replace(/\\/g, '/');
4894
4909
  }
4895
4910
 
4896
- function initializeStagedAppReleaseResources(target, changeId) {
4911
+ function initializeStagedAppReleaseResources(
4912
+ target,
4913
+ changeId,
4914
+ resources = [],
4915
+ metadata = {}
4916
+ ) {
4897
4917
  const context = currentStagedResourcesContext(target, changeId);
4898
4918
  return writeStagedResourcesFiles(
4899
4919
  changeId,
4900
- [],
4920
+ Array.isArray(resources) && resources.length === 0
4921
+ ? []
4922
+ : normalizeStagedAppReleaseResources(resources),
4901
4923
  {
4902
4924
  ...context,
4903
4925
  initializedAt: new Date().toISOString(),
4926
+ ...metadata,
4904
4927
  },
4905
4928
  target.deploymentId
4906
4929
  );
@@ -5083,6 +5106,278 @@ function stagedFormCode(target, resource) {
5083
5106
  return formUuid;
5084
5107
  }
5085
5108
 
5109
+ function reusableStagedFormReleaseCandidates(target, changeId) {
5110
+ const normalizedChangeId = String(changeId || '').trim();
5111
+ if (!normalizedChangeId) return [];
5112
+ const file = stagedResourcesFileForChange(
5113
+ normalizedChangeId,
5114
+ target.deploymentId
5115
+ );
5116
+ if (!fs.existsSync(file)) return [];
5117
+ const contextFile = stagedResourcesContextFileForChange(
5118
+ normalizedChangeId,
5119
+ target.deploymentId
5120
+ );
5121
+ if (!fs.existsSync(contextFile)) {
5122
+ fail(
5123
+ `FORM_RELEASE_REUSE_CONTEXT_REQUIRED: change ${normalizedChangeId} 的 staged FormRelease 缺少 context,拒绝重挂接`
5124
+ );
5125
+ }
5126
+ const context = JSON.parse(fs.readFileSync(contextFile, 'utf8'));
5127
+ const expected = {
5128
+ contractVersion: 'staged_resources_context_v1',
5129
+ appType: target.appType,
5130
+ profile: target.profileName,
5131
+ changeId: normalizedChangeId,
5132
+ deploymentId: target.deploymentId || null,
5133
+ };
5134
+ for (const [field, value] of Object.entries(expected)) {
5135
+ if ((context?.[field] ?? null) !== (value ?? null)) {
5136
+ fail(
5137
+ `FORM_RELEASE_REUSE_CONTEXT_MISMATCH: ${field} 不属于当前 app/profile/change/deployment,拒绝重挂接 staged FormRelease`
5138
+ );
5139
+ }
5140
+ }
5141
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
5142
+ const resources = Array.isArray(parsed) && parsed.length === 0
5143
+ ? []
5144
+ : normalizeStagedAppReleaseResources(parsed);
5145
+ let scopedForms = null;
5146
+ try {
5147
+ scopedForms = new Set(
5148
+ getSddChangeScope({
5149
+ cwd: process.cwd(),
5150
+ configText: readWorkspaceConfigText(),
5151
+ changeId: normalizedChangeId,
5152
+ }).targets.forms || []
5153
+ );
5154
+ } catch (error) {
5155
+ // A direct release may use --change without a local SDD record. The
5156
+ // exact local context still supplies the app/profile/change scope, while
5157
+ // every candidate is independently verified against immutable server data.
5158
+ if (!/SDD 未初始化|change 不存在|ENOENT/i.test(String(error?.message || ''))) {
5159
+ throw error;
5160
+ }
5161
+ }
5162
+ return resources
5163
+ .filter(resource => resource.kind === 'FormRelease')
5164
+ .map(resource => {
5165
+ const formCode = stagedFormCode(target, resource);
5166
+ if (
5167
+ scopedForms &&
5168
+ (!formCode || !scopedForms.has(formCode))
5169
+ ) {
5170
+ fail(
5171
+ `FORM_RELEASE_REUSE_SCOPE_MISMATCH: ${formCode || resource.identity?.formUuid || '(unknown)'} 不在 change ${normalizedChangeId} 的精确 Form 范围内`
5172
+ );
5173
+ }
5174
+ return { resource, formCode };
5175
+ });
5176
+ }
5177
+
5178
+ function nullableReleaseId(value) {
5179
+ const normalized = String(value || '').trim();
5180
+ return normalized || null;
5181
+ }
5182
+
5183
+ async function verifyReusableStagedFormRelease(
5184
+ config,
5185
+ target,
5186
+ candidate
5187
+ ) {
5188
+ const resource = candidate.resource;
5189
+ const formCode = String(candidate.formCode || '').trim();
5190
+ const releaseId = String(resource.identity?.releaseId || '').trim();
5191
+ const formUuid = String(resource.identity?.formUuid || '').trim();
5192
+ const boundFormUuid = String(
5193
+ target.bound.resources?.forms?.[formCode]?.formUuid ||
5194
+ target.bound.resources?.formSettings?.[formCode]?.formUuid ||
5195
+ ''
5196
+ ).trim();
5197
+ if (!formCode || !boundFormUuid || boundFormUuid !== formUuid) {
5198
+ fail(
5199
+ `FORM_RELEASE_REUSE_BINDING_MISMATCH: ${formCode || formUuid || '(unknown)'} 的本地 binding 与 staged FormRelease identity 不一致`
5200
+ );
5201
+ }
5202
+ const basePath = `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/forms/${encodeURIComponent(formUuid)}`;
5203
+ const [detail, head] = await Promise.all([
5204
+ requestWithAuth(
5205
+ config,
5206
+ target.profileName,
5207
+ `${basePath}/releases/${encodeURIComponent(releaseId)}`
5208
+ ),
5209
+ requestWithAuth(
5210
+ config,
5211
+ target.profileName,
5212
+ `${basePath}/releases/head`
5213
+ ),
5214
+ ]);
5215
+ if (
5216
+ detail?.immutable !== true ||
5217
+ detail?.active === true ||
5218
+ detail?.aborted === true ||
5219
+ (Array.isArray(detail?.journal) ? detail.journal : []).some(
5220
+ entry => entry?.action === 'abort'
5221
+ )
5222
+ ) {
5223
+ fail(
5224
+ `FORM_RELEASE_REUSE_STATE_INVALID: ${formCode} 的 FormRelease ${releaseId} 必须 immutable=true、active=false、aborted=false`
5225
+ );
5226
+ }
5227
+ if (
5228
+ String(detail?.id || '').trim() !== releaseId ||
5229
+ String(detail?.formUuid || '').trim() !== formUuid
5230
+ ) {
5231
+ fail(
5232
+ `FORM_RELEASE_REUSE_IDENTITY_MISMATCH: ${formCode} 的 releaseId/formUuid 与服务端冻结证据不一致`
5233
+ );
5234
+ }
5235
+ const contentHash = String(detail?.contentHash || '')
5236
+ .trim()
5237
+ .toLowerCase();
5238
+ if (
5239
+ !/^[a-f0-9]{64}$/.test(contentHash) ||
5240
+ contentHash !== String(resource.hash || '').trim().toLowerCase()
5241
+ ) {
5242
+ fail(
5243
+ `FORM_RELEASE_REUSE_HASH_MISMATCH: ${formCode} 的 staged hash 与 immutable FormRelease 不一致`
5244
+ );
5245
+ }
5246
+ const parentReleaseId = nullableReleaseId(detail?.parentReleaseId);
5247
+ const baseRevision = Number(detail?.baseRevision);
5248
+ if (
5249
+ nullableReleaseId(resource.revision?.parentReleaseId) !==
5250
+ parentReleaseId ||
5251
+ Number(resource.revision?.baseRevision) !== baseRevision
5252
+ ) {
5253
+ fail(
5254
+ `FORM_RELEASE_REUSE_REVISION_MISMATCH: ${formCode} 的 staged parent/baseRevision 与 immutable FormRelease 不一致`
5255
+ );
5256
+ }
5257
+ const activeHead = head?.activeFormReleaseHead || {};
5258
+ const currentRevision = Number(head?.revision ?? activeHead.revision);
5259
+ if (
5260
+ nullableReleaseId(activeHead.releaseId) !== parentReleaseId ||
5261
+ !Number.isSafeInteger(currentRevision) ||
5262
+ currentRevision !== baseRevision
5263
+ ) {
5264
+ fail(
5265
+ `FORM_RELEASE_REUSE_PARENT_CONFLICT: ${formCode} 的 active head/revision 已变化,拒绝重挂接 staged FormRelease`
5266
+ );
5267
+ }
5268
+ if (
5269
+ !Array.isArray(detail?.resources) ||
5270
+ detail.resources.length === 0 ||
5271
+ detail.resources.some(item => !item?.finalizedAt)
5272
+ ) {
5273
+ fail(
5274
+ `FORM_RELEASE_REUSE_RESOURCES_INVALID: ${formCode} 的 FormRelease 资源尚未全部 finalized`
5275
+ );
5276
+ }
5277
+ const frozenForm = detail?.snapshotJson?.form;
5278
+ if (!frozenForm || frozenForm.schema === undefined || !frozenForm.formType) {
5279
+ fail(
5280
+ `FORM_RELEASE_REUSE_SCHEMA_MISSING: ${formCode} 的 FormRelease 缺少冻结 schema/formType`
5281
+ );
5282
+ }
5283
+ let frozenSchema = frozenForm.schema;
5284
+ if (typeof frozenSchema === 'string') {
5285
+ try {
5286
+ frozenSchema = JSON.parse(frozenSchema);
5287
+ } catch {
5288
+ fail(
5289
+ `FORM_RELEASE_REUSE_SCHEMA_INVALID: ${formCode} 的 FormRelease 冻结 schema 不是合法 JSON`
5290
+ );
5291
+ }
5292
+ }
5293
+ if (!frozenSchema || typeof frozenSchema !== 'object') {
5294
+ fail(
5295
+ `FORM_RELEASE_REUSE_SCHEMA_INVALID: ${formCode} 的 FormRelease 冻结 schema 无效`
5296
+ );
5297
+ }
5298
+ return {
5299
+ resource: {
5300
+ kind: 'FormRelease',
5301
+ identity: { releaseId, formUuid },
5302
+ action: resource.action || 'update',
5303
+ hash: contentHash,
5304
+ revision: { parentReleaseId, baseRevision },
5305
+ metadata: {
5306
+ ...(resource.metadata || {}),
5307
+ formCode,
5308
+ releaseStatus: 'staged',
5309
+ },
5310
+ },
5311
+ formCode,
5312
+ formUuid,
5313
+ releaseId,
5314
+ contentHash,
5315
+ baseRevision,
5316
+ parentReleaseId,
5317
+ formSnapshot: {
5318
+ ...clonePlainJson(frozenForm),
5319
+ schema: clonePlainJson(frozenSchema),
5320
+ },
5321
+ formType: frozenForm.formType,
5322
+ };
5323
+ }
5324
+
5325
+ async function resolveReusableStagedFormReleaseOverlays(
5326
+ config,
5327
+ target,
5328
+ changeId,
5329
+ options = {}
5330
+ ) {
5331
+ const requested = new Set(
5332
+ (options.formCodes || [])
5333
+ .map(value => String(value || '').trim())
5334
+ .filter(Boolean)
5335
+ );
5336
+ const candidates = reusableStagedFormReleaseCandidates(target, changeId)
5337
+ .filter(candidate => requested.size === 0 || requested.has(candidate.formCode));
5338
+ const verified = [];
5339
+ for (const candidate of candidates) {
5340
+ verified.push(
5341
+ await verifyReusableStagedFormRelease(config, target, candidate)
5342
+ );
5343
+ }
5344
+ return new Map(verified.map(item => [item.formCode, item]));
5345
+ }
5346
+
5347
+ async function initializeNewReleaseStagedResources(
5348
+ config,
5349
+ target,
5350
+ changeId,
5351
+ candidates
5352
+ ) {
5353
+ const verified = [];
5354
+ for (const candidate of candidates || []) {
5355
+ verified.push(
5356
+ await verifyReusableStagedFormRelease(config, target, candidate)
5357
+ );
5358
+ }
5359
+ const reused = verified.map(item => ({
5360
+ formCode: item.formCode,
5361
+ formUuid: item.formUuid,
5362
+ releaseId: item.releaseId,
5363
+ contentHash: item.contentHash,
5364
+ baseRevision: item.baseRevision,
5365
+ parentReleaseId: item.parentReleaseId,
5366
+ }));
5367
+ const file = initializeStagedAppReleaseResources(
5368
+ target,
5369
+ changeId,
5370
+ verified.map(item => item.resource),
5371
+ reused.length > 0
5372
+ ? {
5373
+ reusedStagedFormReleases: reused,
5374
+ reusedAt: new Date().toISOString(),
5375
+ }
5376
+ : {}
5377
+ );
5378
+ return { file, reused };
5379
+ }
5380
+
5086
5381
  function assertStagedAppReleaseScope(target, flags, resources) {
5087
5382
  const changeId = readStringFlag(flags, 'change');
5088
5383
  if (!changeId) {
@@ -6083,6 +6378,15 @@ async function resolvePublishLeaseForWrite(config, target, flags = {}) {
6083
6378
  const explicitLeaseId = readStringFlag(flags, 'publish-lease-id');
6084
6379
  const changeId = readStringFlag(flags, 'change');
6085
6380
  const storedLease = getStoredPublishLease(target);
6381
+ const baselineAtStart = changeId
6382
+ ? getStoredChangeBaseline(target)
6383
+ : null;
6384
+ const newReleaseLifecycle = Boolean(
6385
+ changeId && !getUsableStoredPublishLease(target) && !baselineAtStart
6386
+ );
6387
+ const reusableFormCandidates = newReleaseLifecycle
6388
+ ? reusableStagedFormReleaseCandidates(target, changeId)
6389
+ : [];
6086
6390
  let acquiredByCommand = false;
6087
6391
  assertOrClaimWorktreeOwner({
6088
6392
  cwd: process.cwd(),
@@ -6152,7 +6456,41 @@ async function resolvePublishLeaseForWrite(config, target, flags = {}) {
6152
6456
  clearRejectedPendingBaseline(target, baseline, error);
6153
6457
  throw error;
6154
6458
  }
6155
- finalizeReleaseBaseline(target, baseline);
6459
+ const finalizedBaseline = finalizeReleaseBaseline(target, baseline);
6460
+ if (newReleaseLifecycle) {
6461
+ try {
6462
+ await initializeNewReleaseStagedResources(
6463
+ config,
6464
+ target,
6465
+ changeId,
6466
+ reusableFormCandidates
6467
+ );
6468
+ } catch (error) {
6469
+ try {
6470
+ await requestWithAuth(
6471
+ config,
6472
+ target.profileName,
6473
+ publishLeaseApiPath(
6474
+ target,
6475
+ `/${encodeURIComponent(lease.leaseId)}/release`
6476
+ ),
6477
+ {
6478
+ method: 'POST',
6479
+ body: { completion: 'local-prepare-failed' },
6480
+ }
6481
+ );
6482
+ } catch {
6483
+ // Keep the staged child verification failure as the primary error.
6484
+ }
6485
+ clearPublishLease(target, lease.leaseId);
6486
+ clearChangeBaseline(
6487
+ target,
6488
+ finalizedBaseline?.baselineId || finalizedBaseline?.id
6489
+ );
6490
+ releaseWorktreeOwner({ cwd: process.cwd() });
6491
+ throw error;
6492
+ }
6493
+ }
6156
6494
  }
6157
6495
  if (!lease) return null;
6158
6496
  if (changeId && lease.changeId !== changeId) {
@@ -12635,9 +12973,34 @@ async function resource(args) {
12635
12973
 
12636
12974
  const validation = validateWorkspaceResources(manifest);
12637
12975
  let target = null;
12976
+ let stagedFormBindingOverlays = new Map();
12638
12977
  if (!isManifestEmpty(manifest)) {
12639
12978
  target = getWorkspaceTarget(config, profileName, flags);
12640
- validateWorkspaceResourceBindings(manifest, target.bound, validation);
12979
+ const workflowFormCodes = unique(
12980
+ (manifest.workflows || [])
12981
+ .map(item => item.formCode || item.form)
12982
+ .filter(Boolean)
12983
+ );
12984
+ const stagedChangeId =
12985
+ readStringFlag(flags, 'change') ||
12986
+ getStoredChangeBaseline(target, { access: 'reconciliation-read' })
12987
+ ?.changeId ||
12988
+ '';
12989
+ if (workflowFormCodes.length > 0 && stagedChangeId) {
12990
+ stagedFormBindingOverlays =
12991
+ await resolveReusableStagedFormReleaseOverlays(
12992
+ config,
12993
+ target,
12994
+ stagedChangeId,
12995
+ { formCodes: workflowFormCodes }
12996
+ );
12997
+ }
12998
+ validateWorkspaceResourceBindings(
12999
+ manifest,
13000
+ target.bound,
13001
+ validation,
13002
+ stagedFormBindingOverlays
13003
+ );
12641
13004
  }
12642
13005
  await validateCompiledWorkflowResources(manifest, validation);
12643
13006
  if (subcommand === 'validate') {
@@ -16123,7 +16486,12 @@ function validateWorkspaceResources(manifest) {
16123
16486
  };
16124
16487
  }
16125
16488
 
16126
- function validateWorkspaceResourceBindings(manifest, bound, validation) {
16489
+ function validateWorkspaceResourceBindings(
16490
+ manifest,
16491
+ bound,
16492
+ validation,
16493
+ stagedFormBindingOverlays = new Map()
16494
+ ) {
16127
16495
  for (const item of manifest.workflows || []) {
16128
16496
  if (!item.formCode && !item.form) continue;
16129
16497
  const formCode = item.formCode || item.form;
@@ -16136,14 +16504,25 @@ function validateWorkspaceResourceBindings(manifest, bound, validation) {
16136
16504
  continue;
16137
16505
  }
16138
16506
  const localSchemaPath = path.join(process.cwd(), 'src', 'forms', formCode, 'schema.ts');
16139
- if (fs.existsSync(localSchemaPath) && !formBinding.schemaSyncedAt) {
16507
+ const stagedOverlay = stagedFormBindingOverlays.get(formCode);
16508
+ if (
16509
+ fs.existsSync(localSchemaPath) &&
16510
+ !formBinding.schemaSyncedAt &&
16511
+ !stagedOverlay
16512
+ ) {
16140
16513
  validation.errors.push(
16141
16514
  `${resourceLabel('workflow', item)}: formCode ${formCode} 已绑定但本地 schema 尚未同步。请先运行 openxiangda workspace publish --profile <name> --form ${formCode}`
16142
16515
  );
16143
16516
  }
16144
- if (formBinding.formType && formBinding.formType !== 'process') {
16517
+ if (stagedOverlay) {
16518
+ validation.warnings.push(
16519
+ `${resourceLabel('workflow', item)}: formCode ${formCode} 使用已验证 staged FormRelease ${stagedOverlay.releaseId} 的冻结 schema/formType`
16520
+ );
16521
+ }
16522
+ const effectiveFormType = stagedOverlay?.formType || formBinding.formType;
16523
+ if (effectiveFormType && effectiveFormType !== 'process') {
16145
16524
  validation.errors.push(
16146
- `${resourceLabel('workflow', item)}: formCode ${formCode} 当前 formType=${formBinding.formType},流程表单需要 process`
16525
+ `${resourceLabel('workflow', item)}: formCode ${formCode} 当前 formType=${effectiveFormType},流程表单需要 process`
16147
16526
  );
16148
16527
  }
16149
16528
  }
@@ -17397,8 +17776,12 @@ async function buildResourcePlan(
17397
17776
  await prepareManifestJsCodeBundlesForPlan(manifest);
17398
17777
  const stagedLocalFormSnapshots =
17399
17778
  await resolveStagedLocalFormContractSnapshots(
17779
+ config,
17400
17780
  target,
17401
- options.flags || {}
17781
+ options.flags || {},
17782
+ (manifest.workflows || [])
17783
+ .map(item => item.formCode || item.form)
17784
+ .filter(Boolean)
17402
17785
  );
17403
17786
  const [existing, formFieldContracts] = await Promise.all([
17404
17787
  fetchExistingResourceMaps(config, target, manifest),
@@ -17828,8 +18211,9 @@ async function ensureOwnedImmutableReleasePublishContext(
17828
18211
  `${releaseKind} 只接受与本地冻结基线匹配的 stored lease;外部 opaque lease 不能用于原子发布`
17829
18212
  );
17830
18213
  }
17831
- const sourceRevision = normalizeManagedReleaseSourceRevision(
18214
+ const sourceRevision = normalizeReleaseSourceRevisionForBaseline(
17832
18215
  target,
18216
+ baseline.sourceBase || {},
17833
18217
  baseline.releaseSourceRevision || {},
17834
18218
  { errorCode: 'RELEASE_SOURCE_REPOSITORY_MISMATCH' }
17835
18219
  );
@@ -26855,32 +27239,38 @@ async function runWorkspaceChildCommand(command, args, options = {}) {
26855
27239
  }
26856
27240
 
26857
27241
  async function resolveStagedLocalFormContractSnapshots(
27242
+ config,
26858
27243
  target,
26859
- flags = {}
27244
+ flags = {},
27245
+ implicitFormCodes = []
26860
27246
  ) {
26861
- const formCodes = unique(
27247
+ const explicitFormCodes = unique(
26862
27248
  String(flags['staged-form-contracts'] || '')
26863
27249
  .split(',')
26864
27250
  .map(value => value.trim())
26865
27251
  .filter(Boolean)
26866
27252
  ).sort();
27253
+ const formCodes = unique([
27254
+ ...explicitFormCodes,
27255
+ ...(implicitFormCodes || [])
27256
+ .map(value => String(value || '').trim())
27257
+ .filter(Boolean),
27258
+ ]).sort();
26867
27259
  if (formCodes.length === 0) return new Map();
26868
27260
  const changeId = readStringFlag(flags, 'change');
26869
27261
  if (!changeId) {
27262
+ if (explicitFormCodes.length === 0) return new Map();
26870
27263
  fail(
26871
27264
  'FORM_FIELD_STAGED_CONTRACT_CHANGE_REQUIRED: --staged-form-contracts 必须与 --change 一起使用'
26872
27265
  );
26873
27266
  }
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
- : [];
27267
+ const stagedOverlays =
27268
+ await resolveReusableStagedFormReleaseOverlays(
27269
+ config,
27270
+ target,
27271
+ changeId,
27272
+ { formCodes }
27273
+ );
26884
27274
  const snapshots = new Map();
26885
27275
  for (const formCode of formCodes) {
26886
27276
  const formUuid = resolveManifestFormUuid(
@@ -26888,29 +27278,15 @@ async function resolveStagedLocalFormContractSnapshots(
26888
27278
  { formCode },
26889
27279
  { fallbackToCode: false }
26890
27280
  );
26891
- const staged = resources.find(
26892
- resource =>
26893
- resource.kind === 'FormRelease' &&
26894
- String(resource.identity?.formUuid || '').trim() === formUuid
26895
- );
26896
- if (!staged) {
27281
+ const staged = stagedOverlays.get(formCode);
27282
+ if (!staged || staged.formUuid !== formUuid) {
26897
27283
  fail(
26898
27284
  `FORM_FIELD_STAGED_CONTRACT_REQUIRED: ${formCode} 尚未在 change ${changeId} 的当前 deployment 中获得 verified FormRelease`
26899
27285
  );
26900
27286
  }
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
27287
  snapshots.set(
26912
27288
  formUuid,
26913
- collectFormSnapshotFieldIds({ form: { schema } })
27289
+ collectFormSnapshotFieldIds({ form: staged.formSnapshot })
26914
27290
  );
26915
27291
  }
26916
27292
  return snapshots;
@@ -118,7 +118,7 @@ Reviewed bundle commands may retain `<profile>` as a template. The explicit real
118
118
 
119
119
  Exact `resourceSelectors` are authoritative for supported configuration resources such as `publicAccessPolicies`. A legacy `resources=true` category marker is narrowed by those exact selectors and must not become an app-wide generic-resource release. Missing selectors, unknown resource types, wildcard `*`, deletes, and genuine app-wide resource closures remain fail closed.
120
120
 
121
- `release begin --change` freezes a clean committed `HEAD` only when the current branch is the authoritative default `main`/`master` and its commit exactly equals the live remote tip. Before any live write, the CLI preflights the complete target set and rejects source changes during the release. A feature worktree or unpushed main receives `RELEASE_SOURCE_MAINLINE_REQUIRED` / `RELEASE_SOURCE_MAINLINE_NOT_PUSHED`; merge, test, push, and start the one mainline release instead of forcing it. Optional `.git` remote suffix differences are aliases of the same repository; genuinely different remotes still fail closed.
121
+ `release begin --change` freezes a clean committed `HEAD` only when the current branch is the authoritative default `main`/`master` and its commit exactly equals the live remote tip. Before any live write, the CLI preflights the complete target set and rejects source changes during the release. A feature worktree or unpushed main receives `RELEASE_SOURCE_MAINLINE_REQUIRED` / `RELEASE_SOURCE_MAINLINE_NOT_PUSHED`; merge, test, push, and start the one mainline release instead of forcing it. Optional `.git` remote suffix differences and other recorded repository aliases are accepted only when the frozen source-base ID intersects `releaseSourceRevision.repoAliases`; Backend, Workflow, and Root App Release writes then use the frozen canonical ID. Genuinely different identity sets still fail closed before prepare.
122
122
 
123
123
  Because promotion begins from the already-pushed authoritative mainline, `release integration-status` should pass immediately after activation. Run it, then `release end`; no post-release branch merge is required.
124
124
 
@@ -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.
@@ -63,6 +63,8 @@ Function/Automation source analysis also extracts statically declared Form filte
63
63
 
64
64
  Source-triggered Function/Automation targets use Backend Release v2 when the platform exposes that capability. One child may mix source-backed create, source-free declarative Automation manifest create, source-only update, and manifest replacement update through explicit per-resource `operation/mode`; the CLI freezes the current Backend Release parent plus Git/change baseline, then runs `prepare -> verify -> activate` or stops verified for `--stage-only`. 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. Activation CAS-checks the entire set and applies all updates in one transaction. `--stage-only` and Secret-bound Function publishing fail closed when Backend Release v2 is unavailable; compatibility fallback is limited to non-staged, non-Secret publishing after an explicit Backend head 404. Existing online bindings, contracts, metadata, trigger/view configuration, and enabled/published state remain unchanged; noops do not advance versions/timestamps. A deliberate whole-definition replacement of an existing resource requires exact `--only/--code` and `--replace-manifest --reason "<why>"`; SDD bypass does not imply replacement authority.
65
65
 
66
+ Repository identity remains strict across immutable releases. If a clone-derived primary repository ID differs from the frozen change source base, the CLI may canonicalize Backend, Workflow, and Root App Release payloads to the frozen ID only when that ID is already present in `releaseSourceRevision.repoAliases`; no alias intersection fails with `RELEASE_SOURCE_REPOSITORY_MISMATCH` before prepare.
67
+
66
68
  Functions with a top-level `secretRefs` field use `backend_release_v2`, including an explicit empty list that removes bindings. This path never falls back to source PATCH. It requires the per-app Secret capability probe to grant `app_function_secrets_v1`, `trusted_node_v2`, `backend_release_v2`, and `atomic_staged_children_v2`; otherwise plan/publish fails closed.
67
69
 
68
70
  For whole-app atomic activation, publish the exact mixed Function/Automation scope with `resource publish function,automation --only function:<code>,automation:<code> --stage-only --change <change>`. This stops after Backend Release verify and returns `stagedResource` plus every handled selector; the CLI automatically merges it with other changed staged children in `.openxiangda/releases/<change>/staged-resources.json` for `release app-finalize --staged-resources-json`. A Backend Release that was already activated returns `activeResource` and must not be presented as 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.208",
4
4
  "description": "OpenXiangda CLI, workspace build tools, runtime SDK, and form components.",
5
5
  "private": false,
6
6
  "bin": {
@@ -26,7 +26,7 @@ This is an OpenXiangda React SPA workspace. See [AGENTS.md](mdc:AGENTS.md) for f
26
26
  - L0 read-only/docs/tests need no SDD; L1 narrow reversible fixes record exact scope without a redundant second confirmation; schema, business Functions, Automation/Workflow, permissions, auth/public access, data writes, and runtime/config are L2/L3 full-SDD work. Live evidence/archive are post-release stages.
27
27
  - Very small copy/style/binding changes may use `openxiangda sdd quick <change> ...`; quick mode records exact low-risk scope without a redundant proposal/approval loop when the user already requested it.
28
28
  - SDD is streamlined by default: approval and exact structured scope are hard gates, while unfinished task/evidence/spec prose only warns. Configure `strictDocumentation: true` only when prose must block.
29
- - Before platform writes, run `release begin` from a clean local main/master that exactly equals the authoritative remote tip. Feature branches and unpushed mainline commits fail before any write. After activation, run `integration-status` and `release end`; no post-release merge is needed.
29
+ - Before platform writes, run `release begin` from a clean local main/master that exactly equals the authoritative remote tip. Feature branches and unpushed mainline commits fail before any write. A clone primary may be canonicalized to the frozen repository ID only when that ID is already in `repoAliases`; otherwise release prepare fails closed. After activation, run `integration-status` and `release end`; no post-release merge is needed.
30
30
  - Managed preproduction and production targets own independent app/resource/data identities and side-effect policy. Existing workspaces use `environment attach`; only an appType-matching legacy binding may seed preproduction and production starts empty. Never direct-publish or copy IDs across them; use `openxiangda studio` to inspect candidate, evidence, and drift state. 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.
31
31
  - Change one target's side-effect policy only with `environment policy update <preproduction|production> --side-effect-policy-json <JSON|file> --reason "..."`: run `--dry-run` first, add `--confirm-production` for production, and never use `environment swap` for this. Patch mode validates only supplied fields and preserves unrecognized historical fields; `--full-replace` validates the supplied complete target and intentionally removes omissions. `organizationWrites=explicit_capability_only` removes the environment deny but still requires `app:organization:manage`. Keep release hard gates to explicit scope/profile/target, authorization, clean pushed mainline, immutable identity, CAS/lease, and production confirmation; prose and human acceptance notes are advisory unless strict mode is configured.
32
32
  - Account/role/permission/RBAC/organization-account/query-param authorization work must run `openxiangda design gates --topic permissions --json`, choose `managed-platform-account`, `existing-platform-user-assignment`, `static-role-permission`, or `query-param-context`, and write the permission matrix.
@@ -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,8 +30,9 @@ 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
- - Promotion 必须持有 `release begin/end` 租约;`release begin` 只接受与权威远端 tip 完全一致的 clean main/master。feature branch 或未 push 主线在任何写入前失败。激活后直接运行 `integration-status` 和 `release end`,不再补做发布后合并。
35
+ - Promotion 必须持有 `release begin/end` 租约;`release begin` 只接受与权威远端 tip 完全一致的 clean main/master。feature branch 或未 push 主线在任何写入前失败。clone primary 只有在冻结仓库 ID 已存在于 `repoAliases` 时才会 canonicalize;无交集在 Release prepare 前失败关闭。激活后直接运行 `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,且默认不放开副作用。
36
37
  - 单独修改某个环境的副作用策略只能使用 `environment policy update <preproduction|production> --side-effect-policy-json <JSON|file> --reason "..."`:先执行 `--dry-run`,正式环境额外要求 `--confirm-production`,不得借用 `environment swap`。patch 只校验本次提交字段并原样保留未知历史字段,`--full-replace` 才按完整目标删除遗漏字段。`organizationWrites=explicit_capability_only` 只解除环境级 deny,仍强制 `app:organization:manage`。发布硬门禁只保留明确 scope/profile/target、权限、干净且已推送主线、不可变版本、CAS/租约与生产确认;文案和人工验收说明默认是建议,只有显式 strict 模式才阻断。
37
38
  - React SPA 路由由 `src/app/router.tsx` 管理,前端包通过 `openxiangda runtime deploy` 发布。
@@ -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
 
@@ -82,7 +82,7 @@ App Function 第三方凭据只能在 Function manifest 顶层声明 `secretRefs
82
82
 
83
83
  `openxiangda runtime deploy --no-activate` 会构建并上传不可变预览版本;发布前先提交所有可能进入构建的源码/配置。所有 Runtime deploy(包括 `--no-activate`)都会先获取应用发布 lease,并在任何构建和上传前冻结 clean `HEAD` 与当前 active Runtime 父血缘;旧分支返回 `RUNTIME_SOURCE_BASE_DIVERGED`,不能先上传旧 preview 再激活。`openspec/` SDD 证据和生成/状态目录不算源码 dirty。仅审批的回退可使用 `--allow-runtime-rollback --reason "至少 8 个字符"`;`--no-build` 不会跳过守卫。不要手工修改 `dist/index.html`。
84
84
 
85
- Function/Automation 走 Backend Release v2;同一个 child 可以混合源码 create、无 `sourceFile` 的完整 v3 声明式 Automation manifest create、source-only update 与显式 manifest replacement,并对整个集合做 CAS。声明式 create 自动选路且不需要 `--replace-manifest`;替换已有资源才需要该显式授权。正式多资源发布必须使用 canonical 精确 selector 和 `--stage-only`。`release begin` 只接受与权威远端默认主分支完全一致的 clean HEAD;feature branch 或未 push 的 main 会在任何平台写入前失败。成功激活后主线证据天然成立,不再补做发布后合并。
85
+ Function/Automation 走 Backend Release v2;同一个 child 可以混合源码 create、无 `sourceFile` 的完整 v3 声明式 Automation manifest create、source-only update 与显式 manifest replacement,并对整个集合做 CAS。声明式 create 自动选路且不需要 `--replace-manifest`;替换已有资源才需要该显式授权。正式多资源发布必须使用 canonical 精确 selector 和 `--stage-only`。`release begin` 只接受与权威远端默认主分支完全一致的 clean HEAD;feature branch 或未 push 的 main 会在任何平台写入前失败。clone primary 与冻结仓库 ID 不同时,只有该冻结 ID 已存在于 `repoAliases` 才会统一用于 Backend/Workflow/Root App Release;无交集继续失败关闭。成功激活后主线证据天然成立,不再补做发布后合并。
86
86
 
87
87
  ## 应用结构
88
88
 
@@ -30,8 +30,9 @@ 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
- - 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
+ - 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. A clone primary may be canonicalized to the frozen repository ID only when that ID is already in `repoAliases`; otherwise release prepare fails closed. 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.
36
37
  - Change one target's side-effect policy only with `environment policy update <preproduction|production> --side-effect-policy-json <JSON|file> --reason "..."`: run `--dry-run` first, add `--confirm-production` for production, and never use `environment swap` for this. Patch mode validates only supplied fields and preserves unrecognized historical fields; `--full-replace` validates the supplied complete target and intentionally removes omissions. `organizationWrites=explicit_capability_only` removes the environment deny but still requires `app:organization:manage`. Keep release hard gates to explicit scope/profile/target, authorization, clean pushed mainline, immutable identity, CAS/lease, and production confirmation; prose and human acceptance notes are advisory unless strict mode is configured.
37
38
  - Routine edits should plan and publish exact change targets: `workspace plan --profile <name> --change <change> --changed`, then `workspace publish --profile <name> --change <change> --only pages/a,forms/b --dry-run`.
@@ -30,8 +30,9 @@ 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
- - 写入平台前只从与权威远端 tip 完全一致的 clean main/master 执行 `release begin`。feature branch 或未 push 主线在任何写入前失败;激活后直接运行 `integration-status` 和 `release end`,无需发布后再合并。
35
+ - 写入平台前只从与权威远端 tip 完全一致的 clean main/master 执行 `release begin`。feature branch 或未 push 主线在任何写入前失败;clone primary 只有在冻结仓库 ID 已存在于 `repoAliases` 时才会 canonicalize,无交集在 Release prepare 前失败关闭;激活后直接运行 `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,且默认不放开副作用。
36
37
  - 单独修改某个环境的副作用策略只能使用 `environment policy update <preproduction|production> --side-effect-policy-json <JSON|file> --reason "..."`:先执行 `--dry-run`,正式环境额外要求 `--confirm-production`,不得借用 `environment swap`。patch 只校验本次提交字段并原样保留未知历史字段,`--full-replace` 才按完整目标删除遗漏字段。`organizationWrites=explicit_capability_only` 只解除环境级 deny,仍强制 `app:organization:manage`。发布硬门禁只保留明确 scope/profile/target、权限、干净且已推送主线、不可变版本、CAS/租约与生产确认;文案和人工验收说明默认是建议,只有显式 strict 模式才阻断。
37
38
  - 单文件改动默认按 change 和逻辑目标发布:先 `workspace plan --profile <name> --change <change> --changed`,再 `workspace publish --profile <name> --change <change> --only pages/a,forms/b --dry-run`。
@@ -55,7 +55,8 @@
55
55
  - ✅ 发现平台缺陷、能力缺口、规则不清、反复 workaround、AI 不确定点、用户可见体验问题时,主动 `openxiangda feedback submit --yes`;发送后告诉用户反馈内容和 fingerprint。
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
- - ✅ 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 "..."`。
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 "..."`。clone primary 与冻结仓库 ID 不同时,只有冻结 ID 已存在于 `repoAliases` 才会统一用于 Backend/Workflow/Root App Release;无交集继续失败关闭。
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 仍是硬门禁。