@gitroll/tacitt-cli 0.6.10 → 0.6.12

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.
Files changed (63) hide show
  1. package/README.md +47 -4
  2. package/bin/INDEX.md +6 -4
  3. package/bin/tacitt.cjs +1 -0
  4. package/dist/{agent-setup-LPd8WEsN.mjs → agent-setup-B12VbvN_.mjs} +2 -2
  5. package/dist/agent-setup-worker.mjs +5 -5
  6. package/dist/cli.mjs +39 -25
  7. package/dist/codex-title-worker.mjs +2 -2
  8. package/dist/configured-preview-worker.mjs +5 -5
  9. package/dist/configured-session-worker.mjs +27 -27
  10. package/dist/{frame-ByT1xayJ.mjs → frame-B3He21G8.mjs} +1 -1
  11. package/dist/{heap-CM298Yu0.mjs → heap-Blrat--4.mjs} +469 -30
  12. package/dist/inspection-worker.mjs +4 -4
  13. package/dist/{job-BHIwElNF.mjs → job-CBuIMTQ0.mjs} +57 -22
  14. package/dist/{locate-Cwv6WH8n.mjs → locate-D-ZaHpI0.mjs} +184 -18
  15. package/dist/{model-zvn5GUJ9.mjs → model-DR9odhWP.mjs} +1 -1
  16. package/dist/operation-executor.mjs +3 -3
  17. package/dist/preview-worker.mjs +1 -1
  18. package/dist/privacy-validator.mjs +49 -2
  19. package/dist/{query-By41gaKm.mjs → query-BYDcNKy2.mjs} +1869 -716
  20. package/dist/{run-ZQx7p4Rw.mjs → run-DQ9xyKSt.mjs} +4 -4
  21. package/dist/session-process.mjs +20 -0
  22. package/dist/{src-D_DfY--2.mjs → src-CKQlTA2Z.mjs} +297 -207
  23. package/dist/{src-3jvMvSQn.mjs → src-DeNJ8vOD.mjs} +535 -140
  24. package/dist/{src-BqtdFVq7.mjs → src-Do-qzuMn.mjs} +2329 -1786
  25. package/dist/store-database-worker.mjs +1 -1
  26. package/dist/web/assets/index-BgDdPL1c.js +172 -0
  27. package/dist/web/assets/index-DzOozqms.css +1 -0
  28. package/dist/web/index.html +3 -2
  29. package/package.json +2 -1
  30. package/skills/INDEX.md +6 -4
  31. package/skills/tacitt/INDEX.md +7 -6
  32. package/skills/tacitt/SKILL.md +11 -11
  33. package/skills/tacitt/references/INDEX.md +14 -11
  34. package/skills/tacitt/references/b-method.md +4 -4
  35. package/skills/tacitt/references/bootstrap.md +19 -5
  36. package/skills/tacitt/references/classification.md +4 -4
  37. package/skills/tacitt/references/commands.md +51 -34
  38. package/skills/tacitt/references/journey.md +7 -7
  39. package/skills/tacitt/references/parallel-review.md +35 -35
  40. package/skills/tacitt/references/send.md +15 -6
  41. package/skills/tacitt/scripts/INDEX.md +40 -37
  42. package/skills/tacitt/scripts/answer.mjs +91 -15
  43. package/skills/tacitt/scripts/journey-card-consolidation.mjs +1 -1
  44. package/skills/tacitt/scripts/journey-classification-metadata.mjs +75 -26
  45. package/skills/tacitt/scripts/journey-classification-progress.mjs +1 -2
  46. package/skills/tacitt/scripts/journey-classification-tasks.mjs +28 -8
  47. package/skills/tacitt/scripts/journey-classification.mjs +6 -1
  48. package/skills/tacitt/scripts/journey-consolidation.mjs +1 -0
  49. package/skills/tacitt/scripts/journey-panel-host.d.mts +2 -0
  50. package/skills/tacitt/scripts/journey-panel-host.mjs +25 -2
  51. package/skills/tacitt/scripts/journey-scan-timing.d.mts +5 -0
  52. package/skills/tacitt/scripts/journey-scan-timing.mjs +31 -4
  53. package/skills/tacitt/scripts/journey-state.d.mts +9 -1
  54. package/skills/tacitt/scripts/journey-state.mjs +95 -8
  55. package/skills/tacitt/scripts/journey-tasks.d.mts +1 -0
  56. package/skills/tacitt/scripts/journey-tasks.mjs +24 -9
  57. package/skills/tacitt/scripts/journey.d.mts +44 -31
  58. package/skills/tacitt/scripts/journey.mjs +34 -7
  59. package/skills/tacitt/scripts/privacy-validator.mjs +188 -11
  60. package/skills/tacitt/scripts/review.d.mts +7 -6
  61. package/skills/tacitt/scripts/review.mjs +922 -205
  62. package/dist/web/assets/index-B14e6_-z.js +0 -174
  63. package/dist/web/assets/index-CYJBq1px.css +0 -1
@@ -1,10 +1,11 @@
1
- import { $ as createCleanup, B as parallelismLimits, C as readOriginalSearchFields, F as SessionWorkerError, G as processJourneyMetadataFields, H as privateJourneyDirectory, I as sessionWorkerFailure, K as staticMaskCategory, O as captureLogicalJourneySources, P as createSharedSessionPool, Q as removeNamedTemporarySpools, R as defaultParallelism, T as createCollectionArtifacts, U as readLogicalJourneyFinalMasks, W as readConfiguredOutputInfo, Z as createTemporary, _ as processJourneyMetadata, a as scanReviewFields, at as windowsOwnerOnly, c as readSessionScanRecords, g as readCapturedOriginalRows, i as buildWorkScanBatches, it as restrictToOwner, j as runConfiguredSessionJob, k as capturedJourneyScope, l as scanPreparedSession, m as openConfiguredUpload, n as verifyPreparedSessionSources, nt as makePrivateDirectory, o as createWorkPieces, ot as isWindowsAclFailure, p as configuredSessionUploads, q as readWorkspacePair, r as prepareSessionScans, rt as openPrivateFile, s as readSessionScanFields, st as windowsAclCodes, t as preparedScanConfiguration, v as readStaticSessionMasks, w as readConfiguredSearchFields, y as staticSessionJobs, z as gitSeedsProvider } from "./src-3jvMvSQn.mjs";
2
- import { A as createExecutionPort, C as saveRemoteState, D as createPackagingProgressPort, E as exportDelivery, F as loadDeliveryResults, L as inBackgroundStoreLane, M as withStateMutex, N as createDeliveryService, O as createStore, P as preflight, R as isBackgroundStatusPoll, S as readRemoteState, T as collectRemoteWorkers, _ as staticBaseProduction, a as writeJourneyOutput, b as workApproval, c as mergeHideIndexTokens, f as writeJourneyBase, g as staticBaseInputs, h as staticSessions, l as packRenderHideIndex, m as saveStaticSession, o as createHideIndexTokens, p as readStaticResult, s as hideIndexTexts, t as journeyOutputJob, u as journeyBaseJob, v as readSelectedCaptures, w as journeyDirectory, x as isRemoteImport, y as prepareSourcePlan, z as retrySharingViolation } from "./job-BHIwElNF.mjs";
3
- import { $t as safeField, Bt as sessionPeriodSchema, Dn as createStaticProcessor, Fn as entityDictionaryVersion, Ft as termTypeInfo, Gt as ownerProfileSchema, Kt as passportAddressSchema, Ot as privacyTermKey, Qt as safeCode, Sn as currentDetectorVersion, T as bLiteralTerms, Un as frameRecords, Wt as mergeOwnerProfile, Xt as ApplicationError, Zt as UnfinishedJourneyError, an as observeSource, ar as admitCustomTerm, c as textDigest, ir as createEntityMatcher, nn as reviewFamilies, or as admitGitName, qt as profileHasChoices, tn as familySelected, ur as ProcessingError, zt as filterInventoryByPeriod } from "./locate-Cwv6WH8n.mjs";
4
- import { a as mainTextChunks, c as previewInWorker, i as nearestSortingRepository, l as createNativeAdapters, n as createProjectEvidenceCollector, o as mainTextFields, r as sortingRepositoryId, s as textChunks, t as createHarnesses, u as createGitNameSeedProvider } from "./src-D_DfY--2.mjs";
5
- import { $r as isClassifying, $t as searchScopeDigest, Ar as displayedScopeDigest, At as obsoleteJourney, B as createApplication, Br as staticReviewDigest, Bt as classificationPartition, Ct as sendActionSchema, Dt as sendStartSchema, Et as sendRequestSchema, F as readAccountAvatarUrl, Gr as agentSourcesSchema, H as inspectionStartSchema, Ht as applyClassification, I as storedCredentialSchema, Jn as workDecisionDigest, Jr as sendReceiptSchema, K as inspectionQuerySchema, Kr as journeySendSchema, Ln as bTermMatcher, M as deviceAuthorizationSchema, N as deviceProfileScopeSchema, Nr as assertJourneyPrivacyReady, O as createAccount, P as profileUrlMatchesIdentity, Pr as privacyDecisionDigest, Qr as hasCompleteWorkDescription, St as replaceSensitiveTermsSchema, Tt as sendPrepareSchema, U as validateInspectionBatch, Ut as retireScanGeneration, V as inspectionCancelSchema, Vn as confirmedHideKinds, Vt as classificationWorks, Wn as journeyConfirmedHides, Wr as agentSourceSchema, Wt as newJourneyWork, Xn as workCorrectionBinding, Xr as sendResolutionSchema, Yr as sendReplaceResultSchema, Zr as sendSettingsSchema, Zt as journeySearchResultSchema, _ as changeJourney, _t as manualUploadStartSchema, ai as withReadingProgress, at as appendSensitiveTerms, ci as selectionBatchSchema, cr as scanSessionResourceSchema, di as producerVersion, dr as verifiedScanGitSeeds, dt as validateFailureLocation, ei as isIncludedWork, fr as scanExclusionResourceSchema, ft as manualUploadActionSchema, gt as manualUploadSchema, h as hideScopeDigest, ht as manualUploadPrepareSchema, j as accountProfileSchema, jr as requestedScope, k as renew, kt as currentJourneyAdapters, li as selectionSchema, lr as scanGitSeedsId, lt as artifactFailureDetailSchema, m as activeJourneyTerms, mi as recommendParallelism, mt as manualUploadChoiceSchema, ni as isSelectedWork, nt as settingsQuerySchema, oi as commandSchema, qn as retireWorkDescription, qr as sendArtifactSchema, si as packageCommandSchema, sr as scanPreparationDigest, ti as isReadingComplete, tt as settingsBatchSchema, ur as scanGitSeedsResource, wt as sendChoiceSchema, xt as normalizeSensitiveTerms, yr as emptyPreparationFacts, yt as emptyTermsRevision, zr as hasCurrentStaticReview } from "./src-BqtdFVq7.mjs";
6
- import { _t as isCoordinationBusy } from "./heap-CM298Yu0.mjs";
7
- import { t as createAgentSetupPort } from "./agent-setup-LPd8WEsN.mjs";
1
+ import { $ as decodeZstd, A as captureLogicalJourneySources, B as defaultParallelism, D as createCollectionArtifacts, E as readConfiguredSearchFields, G as readLogicalJourneyFinalMasks, H as parallelismLimits, I as createSharedSessionPool, J as staticMaskCategory, K as readConfiguredOutputInfo, L as SessionWorkerError, N as runConfiguredSessionJob, R as sessionWorkerFailure, T as readOriginalSearchFields, V as gitSeedsProvider, W as privateJourneyDirectory, Y as readWorkspacePair, a as prepareSessionScans, at as makePrivateDirectory, b as readStaticSessionMasks, c as createWorkPieces, ct as windowsOwnerOnly, d as scanPreparedSession, et as createTemporary, g as openConfiguredUpload, h as configuredSessionUploads, i as verifyPreparedSessionSources, j as capturedJourneyScope, l as readSessionScanFields, lt as isWindowsAclFailure, n as sessionExtensions, nt as createCleanup, o as buildWorkScanBatches, ot as openPrivateFile, q as processJourneyMetadataFields, r as preparedScanConfiguration, s as scanReviewFields, st as restrictToOwner, t as hasRetiredSessionMarker, tt as removeNamedTemporarySpools, u as readSessionScanRecords, ut as windowsAclCodes, v as readCapturedOriginalRows, x as staticSessionJobs, y as processJourneyMetadata } from "./src-DeNJ8vOD.mjs";
2
+ import { A as createExecutionPort, C as saveRemoteState, D as createPackagingProgressPort, E as exportDelivery, F as loadDeliveryResults, L as inBackgroundStoreLane, M as withStateMutex, N as createDeliveryService, O as createStore, P as preflight, R as isBackgroundStatusPoll, S as readRemoteState, T as collectRemoteWorkers, _ as staticBaseProduction, a as writeJourneyOutput, b as workApproval, c as mergeHideIndexTokens, f as writeJourneyBase, g as staticBaseInputs, h as staticSessions, l as packRenderHideIndex, m as saveStaticSession, o as createHideIndexTokens, p as readStaticResult, s as hideIndexTexts, t as journeyOutputJob, u as journeyBaseJob, v as readSelectedCaptures, w as journeyDirectory, x as isRemoteImport, y as prepareSourcePlan, z as retrySharingViolation } from "./job-CBuIMTQ0.mjs";
3
+ import { $t as profileHasChoices, D as bLiteralTerms, Gt as filterInventoryByPeriod, Kt as sessionPeriodSchema, Mt as privacyTermKey, Nn as createStaticProcessor, On as currentDetectorVersion, Qt as passportAddressSchema, Vn as entityDictionaryVersion, Vt as termTypeInfo, Xt as mergeOwnerProfile, Yn as frameRecords, Zt as ownerProfileSchema, an as safeField, br as processingSite, cn as reviewFamilies, dn as observeSource, dr as admitCustomTerm, fr as admitGitName, gr as ProcessingError, in as safeCode, nn as ApplicationError, rn as UnfinishedJourneyError, sn as familySelected, u as textDigest, ur as createEntityMatcher, xr as siteFields } from "./locate-D-ZaHpI0.mjs";
4
+ import { a as mainTextChunks, c as previewInWorker, i as nearestSortingRepository, l as createNativeAdapters, n as createProjectEvidenceCollector, o as mainTextFields, r as sortingRepositoryId, s as textChunks, t as createHarnesses, u as createGitNameSeedProvider } from "./src-CKQlTA2Z.mjs";
5
+ import { An as scanExclusionResourceSchema, B as createApplication, Bt as newJourneyWork, Ci as withReadingProgress, Ct as sendActionSchema, Di as selectionSchema, Dn as scanGitSeedsId, Dt as sendStartSchema, Ei as selectionBatchSchema, En as scanSessionResourceSchema, Et as sendRequestSchema, F as readAccountAvatarUrl, Fr as assertJourneyPrivacyReady, Gr as currentJourneyAdapters, H as inspectionStartSchema, I as storedCredentialSchema, Ir as privacyDecisionDigest, It as classificationPartition, Jr as obsoleteJourney, K as inspectionQuerySchema, Kr as hostDrives, Lt as classificationWorks, M as deviceAuthorizationSchema, Mi as recommendParallelism, Mr as displayedScopeDigest, N as deviceProfileScopeSchema, Nr as requestedScope, O as createAccount, On as scanGitSeedsResource, P as profileUrlMatchesIdentity, Rn as applyClassification, Rt as retireScanGeneration, Si as unfinishedPackagingCode, St as replaceSensitiveTermsSchema, Ti as packageCommandSchema, Tn as scanPreparationDigest, Tt as sendPrepareSchema, U as validateInspectionBatch, Ur as advanceStepsSend, V as inspectionCancelSchema, Vn as searchScopeDigest, Vr as nativeStartSchema, Zr as sendsOnNextAdvance, _ as changeJourney, _i as isIncludedWork, _r as workCorrectionBinding, _t as manualUploadStartSchema, at as appendSensitiveTerms, ci as journeySendSchema, cr as confirmedHideKinds, di as sendReplaceResultSchema, dr as journeyConfirmedHides, dt as validateFailureLocation, fi as sendResolutionSchema, ft as manualUploadActionSchema, gi as isClassifying, gt as manualUploadSchema, h as hideScopeDigest, hi as hasCompleteWorkDescription, hr as workDecisionDigest, ht as manualUploadPrepareSchema, ir as bTermMatcher, j as accountProfileSchema, k as renew, ki as producerVersion, kn as verifiedScanGitSeeds, li as sendArtifactSchema, lt as artifactFailureDetailSchema, m as activeJourneyTerms, mi as sendSettingsSchema, mr as retireWorkDescription, mt as manualUploadChoiceSchema, ni as staticReviewDigest, nt as settingsQuerySchema, oi as agentSourceSchema, qr as itemAtPassport, ri as staticSessionId, si as agentSourcesSchema, ti as hasCurrentStaticReview, tt as settingsBatchSchema, ui as sendReceiptSchema, vi as isReadingComplete, vr as emptyPreparationFacts, wi as commandSchema, wt as sendChoiceSchema, xt as normalizeSensitiveTerms, yi as isSelectedWork, yt as emptyTermsRevision, zn as journeySearchResultSchema, zt as isolatedWorkId } from "./src-Do-qzuMn.mjs";
6
+ import { vt as isCoordinationBusy } from "./heap-Blrat--4.mjs";
7
+ import { d as checkOpenSource, h as openSource } from "./run-DQ9xyKSt.mjs";
8
+ import { t as createAgentSetupPort } from "./agent-setup-B12VbvN_.mjs";
8
9
  import { t as workerMessageSchema } from "./messages-D3m3bhva.mjs";
9
10
  import { constants, write } from "node:fs";
10
11
  import { z } from "zod";
@@ -92,7 +93,7 @@ function permissionDiagnostic(error) {
92
93
  function startupFailure(error, platform = process.platform) {
93
94
  const code = safeCode(error);
94
95
  const permission = permissionDiagnostic(error);
95
- const advice = code === "STATE_CLOSE_FAILED" ? " Keep the state directory intact and stop this CLI before retrying. Closing the state database could not be confirmed; state was not reset." : permission ? " Windows permissions could not be verified. Check that system Windows PowerShell can run and access the entry; retry after resolving the reported helper failure. Permissions were not established as unsafe." : code === "OBSOLETE_SCHEMA" ? " This control-plane database uses an older format. --overwrite-corrupted explicitly resets workflow progress, approvals and scan counts; source logs, completed ZIP files and private sensitive terms are retained." : code === "UNSAFE_STATE_PATH" ? platform === "win32" ? " State requires current-user ownership and a private Windows access control list. Repair permissions explicitly or use a private --state-dir." : " State requires current-user ownership, directory mode 0700 and file mode 0600. Repair permissions explicitly or use a private --state-dir." : ["CORRUPTED_STATE", "RESET_INCOMPLETE"].includes(code) ? " Inspect state first; --overwrite-corrupted explicitly resets only control-plane state." : code === "UNSUPPORTED_SCHEMA" ? " Stop this CLI, then run `npx --yes @gitroll/tacitt-cli@latest` with your original options to start the latest release. If you use the Skill, run `npx --yes @gitroll/tacitt-cli@latest skill.install`, then start a fresh conversation and invoke $tacitt in Codex or /tacitt in Claude Code. State was not reset." : code === "STATE_LAYOUT_INVALID" ? " Keep the state directory intact for inspection. Its storage layout cannot be recovered automatically; --overwrite-corrupted does not bypass this check." : code === "DEV_REQUIRES_SOURCE_CHECKOUT" ? " --dev serves the repository UI through Vite and only works from a source checkout; run without --dev." : code === "ARTIFACT_OUTPUT_UNSAFE" ? " Use a writable private --bundle-dir or repair the configured bundle library. The saved directory was not changed." : code === "STATE_IN_USE" ? " Another tacitt process still has this state open. Stop the other tacitt process and retry; state was not reset." : error instanceof UnfinishedJourneyError ? unfinishedJourneyAdvice(error) : " Run --help for usage.";
96
+ const advice = code === "STATE_CLOSE_FAILED" ? " Keep the state directory intact and stop this CLI before retrying. Closing the state database could not be confirmed; state was not reset." : permission ? " Windows permissions could not be verified. Check that system Windows PowerShell can run and access the entry; retry after resolving the reported helper failure. Permissions were not established as unsafe." : code === "OBSOLETE_SCHEMA" ? " This control-plane database uses an older format. --overwrite-corrupted explicitly resets workflow progress, approvals and scan counts; source logs, completed ZIP files and private sensitive terms are retained." : code === "UNSAFE_STATE_PATH" ? platform === "win32" ? " State requires current-user ownership and a private Windows access control list. Repair permissions explicitly or use a private --state-dir." : " State requires current-user ownership, directory mode 0700 and file mode 0600. Repair permissions explicitly or use a private --state-dir." : ["CORRUPTED_STATE", "RESET_INCOMPLETE"].includes(code) ? " Inspect state first; --overwrite-corrupted explicitly resets only control-plane state." : code === "UNSUPPORTED_SCHEMA" ? " Stop this CLI, then run the same command through `npx --yes @gitroll/tacitt-cli@latest` to use the latest release. If you use the Skill, run `npx --yes @gitroll/tacitt-cli@latest skill.install`, then start a fresh conversation and invoke $tacitt in Codex or /tacitt in Claude Code. State was not reset." : code === "STATE_LAYOUT_INVALID" ? " Keep the state directory intact for inspection. Its storage layout cannot be recovered automatically; --overwrite-corrupted does not bypass this check." : code === "DEV_REQUIRES_SOURCE_CHECKOUT" ? " --dev serves the repository UI through Vite and only works from a source checkout; run without --dev." : code === "ARTIFACT_OUTPUT_UNSAFE" ? " Use a writable private --bundle-dir or repair the configured bundle library. The saved directory was not changed." : code === "STATE_IN_USE" ? " Another tacitt process still has this state open. Stop the other tacitt process and retry; state was not reset." : error instanceof UnfinishedJourneyError ? unfinishedJourneyAdvice(error) : " Run --help for usage.";
96
97
  return `${code}.${code === "OPERATION_FAILED" ? failureCause(error) : ""}${permission}${advice}\n`;
97
98
  }
98
99
  const safeJourneyId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
@@ -123,6 +124,7 @@ const businessCommands = [
123
124
  "journey.classificationSessions",
124
125
  "journey.classificationProgress",
125
126
  "journey.classificationSubmit",
127
+ "journey.recoverIsolatedMetadata",
126
128
  "journey.cardsRead",
127
129
  "journey.cardsSubmit",
128
130
  "journey.resolve",
@@ -141,6 +143,7 @@ const businessCommands = [
141
143
  "journey.findingImpact",
142
144
  "journey.files",
143
145
  "journey.prepare",
146
+ "review.reopenEmpty",
144
147
  "review.terms",
145
148
  "review.status",
146
149
  "review.guidance",
@@ -214,6 +217,23 @@ const accountDefaults = {
214
217
  clientId: "BdSyvRSunWdXfJUdfvaKuAgoRjaZQpOZ",
215
218
  passportApiUrl: "https://talent-api.catalyte.ai"
216
219
  };
220
+ /** The web origin paired with each deployed Passport API; custom targets supply theirs. */
221
+ function passportWebOrigin(apiUrl, configured) {
222
+ if (configured) {
223
+ const url = new URL(configured);
224
+ const local = [
225
+ "127.0.0.1",
226
+ "[::1]",
227
+ "localhost"
228
+ ].includes(url.hostname);
229
+ if (url.protocol !== "https:" && !(local && url.protocol === "http:") || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw new Error("INVALID_ARGUMENTS");
230
+ return url.origin;
231
+ }
232
+ return {
233
+ "https://talent-api.catalyte.ai": "https://talent.catalyte.ai",
234
+ "https://talent-api-dev.catalyte.ai": "https://talent-dev.catalyte.ai"
235
+ }[new URL(apiUrl).origin];
236
+ }
217
237
  //#endregion
218
238
  //#region apps/cli/src/options.ts
219
239
  /** An HTTP(S) origin without path, query, credentials or fragment. */
@@ -333,6 +353,7 @@ function parseOptions(args, source = sourceCheckout) {
333
353
  sessionPeriod: period.data,
334
354
  identityUrl: readOrigin(parsed.values["identity-url"] ?? process.env["SESS_PILOT_IDENTITY_URL"] ?? accountDefaults.identityUrl),
335
355
  identityClientId: identityClientId === "" ? null : identityClientId,
356
+ ...process.env["SESS_PILOT_PASSPORT_WEB_ORIGIN"] ? { passportWebOrigin: readOrigin(process.env["SESS_PILOT_PASSPORT_WEB_ORIGIN"]) } : {},
336
357
  passportApiUrl: readOrigin(parsed.values["profile-api-url"] ?? parsed.values["passport-api-url"] ?? process.env["SESS_PILOT_PROFILE_API_URL"] ?? process.env["SESS_PILOT_PASSPORT_API_URL"] ?? accountDefaults.passportApiUrl),
337
358
  stateDir: resolve(parsed.values["state-dir"] ?? defaultState),
338
359
  bundleDir: parsed.values["bundle-dir"] === void 0 ? void 0 : resolve(parsed.values["bundle-dir"]),
@@ -342,13 +363,14 @@ function parseOptions(args, source = sourceCheckout) {
342
363
  }
343
364
  const fullHelp = `tacitt — review and package local session logs
344
365
 
345
- Usage: tacitt [--no-open] [--state-dir PATH] [--bundle-dir PATH] [--parallelism N]
346
- [--since DATE] [--until DATE]
347
- tacitt state dump [--state-dir PATH] [--output PATH]
348
- tacitt status [--state-dir PATH]
366
+ Usage: tacitt skill.install [--agent codex|claude|all] [--scope user|project] [--cli-path PATH]
349
367
  tacitt PROCEDURE [--input FILE|-] [--async] [--timeout-ms N]
350
368
  tacitt operation.get|operation.wait|operation.cancel ID [--timeout-ms N]
351
- tacitt skill.install [--agent codex|claude|all] [--scope user|project] [--cli-path PATH]
369
+ tacitt state dump [--state-dir PATH] [--output PATH]
370
+ tacitt status [--state-dir PATH]
371
+
372
+ Tacitt has three surfaces: the Skill (/tacitt in Claude Code, $tacitt in Codex), the Skill
373
+ panel that the Skill opens, and the CLI commands below. Bare tacitt prints how to start the Skill.
352
374
 
353
375
  Business procedures (JSON output):
354
376
  ${Array.from(businessCommands).join("\n")}
@@ -356,7 +378,8 @@ ${Array.from(businessCommands).join("\n")}
356
378
  Business commands invoke the shared Application directly without an HTTP server.
357
379
  journey.serve starts a focused confirmation host, emits JSON readiness, and waits until stopped.
358
380
  journey.serve --detach starts that host in its own process, prints its readiness with the host's
359
- pid, and exits; stop the host with that pid. A detached host already serving the journey is
381
+ pid, and exits. The launcher verifies the release and process identity before reusing a panel.
382
+ A detached host already serving the journey with the paired release is
360
383
  reused and reported with reused: true.
361
384
  It does not discover sessions or open a browser. The agent opens its private URL in the right pane.
362
385
  Workflow mutations require protocolVersion, runId and expectedRevision in JSON input.
@@ -372,12 +395,11 @@ Workspace ZIPs and collection indexes use the saved bundle library directory.
372
395
  Native Skill Send uploads only the confirmed primary artifacts to the configured Tacitt profile API.
373
396
  status prints safe workspace outcomes from the latest saved packaging operation.
374
397
  Private packaging error details stay in <state-dir>/diagnostics/ (up to 4 MiB).
375
- Sign in from the Web UI to link this tool to your GitRoll Identity account; the CLI contacts
398
+ Sign in from the Skill panel to link this tool to your GitRoll Identity account; the CLI contacts
376
399
  only the Identity and profile API origins below, only when you ask, and keeps the issued
377
400
  token in <state-dir>/account/ (owner-only).
378
401
 
379
402
  Options:
380
- --no-open Print the URL without opening a browser
381
403
  --agent CLIENT skill.install only: codex, claude or all (default: all)
382
404
  --scope SCOPE skill.install only: user or project (default: user)
383
405
  --cli-path PATH skill.install only: bind an absolute installed package executable
@@ -388,10 +410,7 @@ Options:
388
410
  --claude-home PATH Override the Claude configuration root
389
411
  --codex-home PATH Override the Codex configuration root
390
412
  --overwrite-corrupted Explicitly reset unusable control-plane state
391
- --parallelism N Configured packaging worker count (default: the Package dialog recommendation)
392
- --since DATE Manual: preselect only sessions last active on or after DATE
393
- --until DATE Manual: preselect only sessions last active on or before DATE
394
- (DATE: YYYY-MM-DD local day, inclusive, or an ISO timestamp)
413
+ --parallelism N Preparation and packaging worker count (default: recommended for this computer)
395
414
  --identity-url URL Identity origin for sign-in (default: ${accountDefaults.identityUrl})
396
415
  --identity-client-id ID Public device client ID registered for this tool
397
416
  --profile-api-url URL Profile API origin the token is checked against
@@ -617,12 +636,14 @@ async function scanRemoteDispatches(sessions, signal) {
617
636
  const pending = /* @__PURE__ */ new Map();
618
637
  for (const part of session.parts) {
619
638
  signal.throwIfAborted();
620
- if (part.representation !== "jsonl") throw new Error("REMOTE_SOURCE_FORMAT_UNSUPPORTED");
621
- const file = await open(part.path, constants.O_RDONLY | constants.O_NOFOLLOW);
639
+ const mode = part.representation === "jsonl" ? "append" : "snapshot";
640
+ const file = await openSource(part, mode);
622
641
  try {
623
- const before = await file.stat({ bigint: true });
624
- if (String(before.dev) !== part.snapshot.dev || String(before.ino) !== part.snapshot.ino || before.size !== BigInt(part.snapshot.size) || String(before.mtimeNs) !== part.snapshot.mtimeNs || String(before.ctimeNs) !== part.snapshot.ctimeNs) throw new Error("REMOTE_SOURCE_CHANGED");
625
- if (!part.snapshot.size) continue;
642
+ if (!part.snapshot.size) {
643
+ if (part.representation === "jsonl.zst") throw new Error("ARTIFACT_PROCESSING_DECODE_FAILED");
644
+ await checkOpenSource(part, file, mode);
645
+ continue;
646
+ }
626
647
  const stream = file.createReadStream({
627
648
  autoClose: false,
628
649
  start: 0,
@@ -637,7 +658,7 @@ async function scanRemoteDispatches(sessions, signal) {
637
658
  unreadableLines++;
638
659
  cause ??= /* @__PURE__ */ new Error(`line ${location.line}, invalid UTF-8`);
639
660
  };
640
- const lines = frameRecords(numberedChunks(stream, location), "jsonl", 16777216, signal);
661
+ const lines = frameRecords(numberedChunks(part.representation === "jsonl.zst" ? decodeZstd(stream, signal) : stream, location), "jsonl", 16777216, signal);
641
662
  try {
642
663
  for await (const framed of skipLiveTail(lines, location, skipped)) {
643
664
  signal.throwIfAborted();
@@ -671,11 +692,10 @@ async function scanRemoteDispatches(sessions, signal) {
671
692
  end: end && end >= at ? end : at
672
693
  }));
673
694
  windows.push(...found);
674
- pending.set(command.id, found);
695
+ if (found.length) pending.set(command.id, found);
675
696
  }
676
697
  }
677
- const after = await file.stat({ bigint: true });
678
- if (after.mtimeNs !== before.mtimeNs || after.size !== before.size) throw new Error("REMOTE_SOURCE_CHANGED");
698
+ await checkOpenSource(part, file, mode);
679
699
  } finally {
680
700
  stream.destroy();
681
701
  }
@@ -743,7 +763,10 @@ function createRemotePreflight(options) {
743
763
  } catch (error) {
744
764
  signal.throwIfAborted();
745
765
  options.reportFailure(error);
746
- state.failures.push("source-scan");
766
+ if (!state.failures.includes("source-scan")) state.failures.push("source-scan");
767
+ const reason = sourceFailureReason(error);
768
+ const counts = state.sourceFailureCounts ??= {};
769
+ counts[reason] = (counts[reason] ?? 0) + 1;
747
770
  }
748
771
  await saveRemoteState(options.stateDir, input.journeyId, state);
749
772
  }
@@ -780,9 +803,23 @@ function result(state) {
780
803
  })),
781
804
  copied: state.copied.length,
782
805
  failures: state.failures,
806
+ ...state.sourceFailureCounts ? { sourceFailureCounts: state.sourceFailureCounts } : {},
783
807
  ...state.failures.some((failure) => ["source-lines-unreadable", "source-scan"].includes(failure)) ? { warning: state.failures.includes("source-scan") ? "Some remote sources could not be scanned; remote worker discovery may be incomplete." : "Some remote dispatch lines were unreadable; remote worker discovery may be incomplete." } : {}
784
808
  };
785
809
  }
810
+ function sourceFailureReason(error) {
811
+ if (!(error instanceof Error)) return "other";
812
+ if (error instanceof SyntaxError) return "invalid-json";
813
+ if (error.message === "SOURCE_CHANGED") return "source-changed";
814
+ if (error.message === "UNSAFE_SOURCE_PATH") return "source-inaccessible";
815
+ if (error.message === "ARTIFACT_PROCESSING_DECODE_FAILED") return "compressed-decode";
816
+ const code = "code" in error ? error.code : null;
817
+ if (code === "PROCESSING_LIMIT") return "record-limit";
818
+ if (code === "PROCESSING_INVALID_RECORD") return "invalid-utf8";
819
+ if (code === "ENOENT" || code === "ENOTDIR") return "source-missing";
820
+ if (code === "EACCES" || code === "EPERM" || code === "ELOOP") return "source-inaccessible";
821
+ return "other";
822
+ }
786
823
  //#endregion
787
824
  //#region apps/cli/src/journey/confirmed-hides.ts
788
825
  /** The Work's explicit exception values; an occurrence inside one stays visible (ADR 0069). */
@@ -1144,7 +1181,10 @@ function bindJourneyDestination(storage, destination, options = {}) {
1144
1181
  return state;
1145
1182
  };
1146
1183
  const guard = async (id) => {
1147
- if (!verified.has(id)) await current(id);
1184
+ if (verified.has(id)) return;
1185
+ const permission = await storage.preparationPermission(id);
1186
+ if (permission && permission.destination !== destination) throw new ApplicationError("JOURNEY_DESTINATION_CHANGED");
1187
+ if (permission) verified.add(id);
1148
1188
  };
1149
1189
  const readStatus = async (input) => {
1150
1190
  await guard(input.journeyId);
@@ -1152,6 +1192,22 @@ function bindJourneyDestination(storage, destination, options = {}) {
1152
1192
  };
1153
1193
  return {
1154
1194
  current,
1195
+ async readSendOutput(input) {
1196
+ await guard(input.journeyId);
1197
+ return storage.readSendOutput(input);
1198
+ },
1199
+ async preparationState(id) {
1200
+ await guard(id);
1201
+ return storage.preparationState(id);
1202
+ },
1203
+ async preparationPermission(id) {
1204
+ await guard(id);
1205
+ return storage.preparationPermission(id);
1206
+ },
1207
+ async updatePreparation(input) {
1208
+ await guard(input.journeyId);
1209
+ return storage.updatePreparation(input);
1210
+ },
1155
1211
  readInventory: (input) => storage.readInventory({
1156
1212
  ...input,
1157
1213
  expectedDestination: destination
@@ -1205,6 +1261,10 @@ function bindJourneyDestination(storage, destination, options = {}) {
1205
1261
  await guard(input.journeyId);
1206
1262
  return storage.saveResource(input);
1207
1263
  },
1264
+ async receiptRevision(input) {
1265
+ await guard(input.journeyId);
1266
+ return storage.receiptRevision(input);
1267
+ },
1208
1268
  async commitClassification(input) {
1209
1269
  await guard(input.journeyId);
1210
1270
  return storage.commitClassification(input);
@@ -1270,6 +1330,10 @@ function bindJourneyDestination(storage, destination, options = {}) {
1270
1330
  await guard(input.journeyId);
1271
1331
  return storage.submitReview(input);
1272
1332
  },
1333
+ async reopenEmpty(input) {
1334
+ await guard(input.journeyId);
1335
+ return storage.reopenEmpty(input);
1336
+ },
1273
1337
  async recordTerms(input) {
1274
1338
  await guard(input.journeyId);
1275
1339
  return storage.recordTerms(input);
@@ -1305,10 +1369,29 @@ function bindJourneyDestination(storage, destination, options = {}) {
1305
1369
  };
1306
1370
  }
1307
1371
  function bindSendDestination(storage, destination) {
1372
+ const verified = /* @__PURE__ */ new Set();
1373
+ async function verify(id) {
1374
+ if (verified.has(id)) return;
1375
+ const snapshot = await storage.loadSend(id);
1376
+ if (!snapshot) throw new ApplicationError("UNKNOWN_ITEM");
1377
+ check(snapshot.journey, destination);
1378
+ verified.add(id);
1379
+ }
1308
1380
  return {
1309
- async loadSend(id, sendId) {
1310
- const result = await storage.loadSend(id, sendId);
1311
- if (result) check(result.journey, destination);
1381
+ async isCurrentSend(id, sendId, revision) {
1382
+ await verify(id);
1383
+ return storage.isCurrentSend(id, sendId, revision);
1384
+ },
1385
+ async checkpointTransfer(input) {
1386
+ await verify(input.journeyId);
1387
+ return storage.checkpointTransfer(input);
1388
+ },
1389
+ async loadSend(id, sendId, observedRevision) {
1390
+ const result = await storage.loadSend(id, sendId, verified.has(id) ? observedRevision : void 0);
1391
+ if (result) {
1392
+ check(result.journey, destination);
1393
+ verified.add(result.journey.journeyId);
1394
+ }
1312
1395
  return result;
1313
1396
  },
1314
1397
  async commitSend(input) {
@@ -1321,6 +1404,32 @@ function bindSendDestination(storage, destination) {
1321
1404
  };
1322
1405
  }
1323
1406
  //#endregion
1407
+ //#region apps/cli/src/journey/prepare/update.ts
1408
+ /** Preparation owns only the changed Work rows, progress and package invalidation. */
1409
+ async function changePreparation(storage, journeyId, kind, change, attempts = Number.POSITIVE_INFINITY, clearPackageApproval = false) {
1410
+ for (let attempt = 0; attempt < attempts; attempt++) {
1411
+ const before = await storage.preparationState(journeyId);
1412
+ if (!before?.consent) throw new ApplicationError("CONFIRMATION_REQUIRED");
1413
+ const after = change(before);
1414
+ if (after === before) return before;
1415
+ const previous = new Map(before.works.map((work) => [work.id, work]));
1416
+ const result = await storage.updatePreparation({
1417
+ journeyId,
1418
+ expectedRevision: before.revision,
1419
+ requestKey: randomUUID(),
1420
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1421
+ kind,
1422
+ works: after.works.filter((work) => work !== previous.get(work.id)),
1423
+ phase: after.phase,
1424
+ readingComplete: after.readingComplete,
1425
+ readingStartedAt: after.readingStartedAt,
1426
+ clearPackageApproval: clearPackageApproval && before !== after
1427
+ });
1428
+ if (result.status !== "stale") return result.state;
1429
+ }
1430
+ throw new ApplicationError("REVISION_CONFLICT");
1431
+ }
1432
+ //#endregion
1324
1433
  //#region apps/cli/src/journey/works/metadata.ts
1325
1434
  /** Original classification fields remain durable; only the derived display cache is replaced. */
1326
1435
  const classificationMetadataInput = (work) => ({
@@ -1329,7 +1438,7 @@ const classificationMetadataInput = (work) => ({
1329
1438
  description: work.description,
1330
1439
  reason: work.classificationRecommendation?.reason ?? work.reason
1331
1440
  });
1332
- async function prepareClassificationMetadata(storage, state, work, processing, signal, plan) {
1441
+ async function processedClassificationMetadata(storage, state, work, processing, signal, plan) {
1333
1442
  if (state.scope.status !== "confirmed") throw new ApplicationError("CONFIRMATION_REQUIRED");
1334
1443
  if (work.classificationMetadata && (work.explicitTitleId || work.suggestion)) return;
1335
1444
  const identity = classificationMetadataInput(work);
@@ -1368,7 +1477,20 @@ async function prepareClassificationMetadata(storage, state, work, processing, s
1368
1477
  fields: Object.values(raw).map((text) => ({ text }))
1369
1478
  }, signal);
1370
1479
  if (title === void 0 || projectName === void 0 || description === void 0 || reason === void 0) throw new ApplicationError("CORRUPTED_STATE");
1371
- await changeJourney(storage, state.journeyId, "snapshot", (before) => {
1480
+ return {
1481
+ digest,
1482
+ title: title.text,
1483
+ projectName: projectName.text,
1484
+ description: description.text,
1485
+ reason: reason.text,
1486
+ titleOriginalSha256: textDigest(raw.title),
1487
+ titleStaticMasks: [...title.staticMasks]
1488
+ };
1489
+ }
1490
+ async function prepareClassificationMetadata(storage, state, work, processing, signal, plan) {
1491
+ const metadata = await processedClassificationMetadata(storage, state, work, processing, signal, plan);
1492
+ if (!metadata) return;
1493
+ await changePreparation(storage, state.journeyId, "snapshot", (before) => {
1372
1494
  const active = before.works.find((item) => item.id === work.id);
1373
1495
  if (before.scope.status !== "confirmed" || !active || textDigest(JSON.stringify([
1374
1496
  active.inputRevision,
@@ -1376,20 +1498,12 @@ async function prepareClassificationMetadata(storage, state, work, processing, s
1376
1498
  active.suggestion?.id,
1377
1499
  active.explicitTitleId,
1378
1500
  before.exceptions.filter((item) => item.workId === active.id)
1379
- ])) !== digest) throw new ApplicationError("REVISION_CONFLICT");
1501
+ ])) !== metadata.digest) throw new ApplicationError("REVISION_CONFLICT");
1380
1502
  return {
1381
1503
  ...before,
1382
1504
  works: before.works.map((item) => item.id === work.id ? {
1383
1505
  ...item,
1384
- classificationMetadata: {
1385
- digest,
1386
- title: title.text,
1387
- projectName: projectName.text,
1388
- description: description.text,
1389
- reason: reason.text,
1390
- titleOriginalSha256: textDigest(raw.title),
1391
- titleStaticMasks: [...title.staticMasks]
1392
- }
1506
+ classificationMetadata: metadata
1393
1507
  } : item)
1394
1508
  };
1395
1509
  });
@@ -1699,7 +1813,7 @@ async function isolateFailingRoots(storage, journeyId, work, error) {
1699
1813
  });
1700
1814
  if (roots.length === 0 || roots.length === work.rootSessionIds.length) return false;
1701
1815
  for (;;) {
1702
- const state = await storage.current(journeyId);
1816
+ const state = await storage.preparationState(journeyId);
1703
1817
  const current = state?.works.find((item) => item.id === work.id);
1704
1818
  if (!state || state.phase === "paused" || state.pendingHideId || current?.inputRevision !== work.inputRevision || current.status !== "pending") return false;
1705
1819
  if ((await storage.isolateRoots({
@@ -1801,7 +1915,7 @@ function scanFailureCode(reason) {
1801
1915
  }
1802
1916
  /** Resolve only the frozen inspection settings and captures; classification stays unchanged. */
1803
1917
  async function prepareScanContext(storage, journeyId, work, selected, processing, signal, workers) {
1804
- const current = await storage.current(journeyId);
1918
+ const current = await storage.preparationState(journeyId);
1805
1919
  if (!current?.consent || !current.snapshotId) throw new ApplicationError("CONFIRMATION_REQUIRED");
1806
1920
  const active = current.works.find((item) => item.id === work.id);
1807
1921
  if (current.scope.status !== "confirmed" || current.phase === "paused" || active?.access === "drop") throw new ApplicationError("OPERATION_CANCELED");
@@ -2127,7 +2241,7 @@ async function prepareScannedWork(storage, plan, work, directory, hooks, observa
2127
2241
  workspaceId: session.workspaceId,
2128
2242
  sessionId: session.id,
2129
2243
  ...failure.sourcePartId ? { sourcePartId: failure.sourcePartId } : {}
2130
- });
2244
+ }, void 0, failure.cause ?? new ApplicationError(scanFailureCode(failure.reason)));
2131
2245
  const root = families.owners.get(failure.sessionId);
2132
2246
  if (root) failures.set(root.id, failure.cause === void 0 ? scanFailureCode(failure.reason) : safeCode(failure.cause));
2133
2247
  }
@@ -2153,7 +2267,7 @@ async function prepareScannedWork(storage, plan, work, directory, hooks, observa
2153
2267
  ...proof,
2154
2268
  rootSessionIds: excludedRoots
2155
2269
  } : void 0;
2156
- await changeJourney(storage, plan.journeyId, "batch", (state) => {
2270
+ await changePreparation(storage, plan.journeyId, "batch", (state) => {
2157
2271
  const target = state.works.find((item) => item.id === work.id);
2158
2272
  if (state.scope.status !== "confirmed" || state.phase === "paused" || target?.access === "drop") throw new ApplicationError("OPERATION_CANCELED");
2159
2273
  if (target?.inputRevision !== work.inputRevision) throw new ApplicationError("REVISION_CONFLICT");
@@ -2212,7 +2326,7 @@ async function observePreparation(storage, input, observation, operation) {
2212
2326
  //#endregion
2213
2327
  //#region apps/cli/src/journey/prepare/work.ts
2214
2328
  async function prepareJourneyWork(storage, plan, work, directory, processing, hooks, workers, onGeneration, onSubmitted) {
2215
- let state = await storage.current(plan.journeyId);
2329
+ let state = await storage.preparationState(plan.journeyId);
2216
2330
  if (!state?.consent || !state.snapshotId) throw new ApplicationError("CONFIRMATION_REQUIRED");
2217
2331
  if (state.scope.status !== "confirmed" || state.phase === "paused") throw new ApplicationError("OPERATION_CANCELED");
2218
2332
  const selected = workApproval(plan.approval, work.rootSessionIds);
@@ -2270,9 +2384,9 @@ async function prepareJourneyWork(storage, plan, work, directory, processing, ho
2270
2384
  context ??= await resolveContext();
2271
2385
  return prepareScannedWork(storage, plan, work, directory, {
2272
2386
  ...hooks,
2273
- onFailureLocation(location, detail) {
2387
+ onFailureLocation(location, detail, cause) {
2274
2388
  observation.location(location);
2275
- hooks.onFailureLocation?.(location, detail);
2389
+ hooks.onFailureLocation?.(location, detail, cause);
2276
2390
  }
2277
2391
  }, observation, context, onSubmitted);
2278
2392
  });
@@ -2294,7 +2408,7 @@ async function prepareJourneyWork(storage, plan, work, directory, processing, ho
2294
2408
  */
2295
2409
  async function prepareStaticWork(options) {
2296
2410
  const { storage, journeyId, signal } = options;
2297
- const state = await storage.current(journeyId);
2411
+ const state = await storage.preparationState(journeyId);
2298
2412
  const work = state?.works.find((item) => item.id === options.workId);
2299
2413
  if (!state?.snapshotId || !work || !isSelectedWork(work) || !work.rootSessionIds.length) return;
2300
2414
  if (![
@@ -2366,7 +2480,7 @@ async function prepareStaticWork(options) {
2366
2480
  if (failure) throw failure.error;
2367
2481
  signal.throwIfAborted();
2368
2482
  if (resources.length !== jobs.length) throw new ApplicationError("SCOPE_NOT_READY");
2369
- await changeJourney(storage, journeyId, "batch", (current) => {
2483
+ await changePreparation(storage, journeyId, "batch", (current) => {
2370
2484
  const target = current.works.find((item) => item.id === work.id);
2371
2485
  if (current.phase === "paused") throw new ApplicationError("OPERATION_CANCELED");
2372
2486
  if (current.snapshotId !== snapshotId || target?.inputRevision !== work.inputRevision) throw new ApplicationError("REVISION_CONFLICT");
@@ -2403,7 +2517,7 @@ function observeJourneyPause(storage, journeyId, processing, onFailure, requireC
2403
2517
  const closing = new AbortController();
2404
2518
  async function readPermission() {
2405
2519
  for (let attempt = 1;; attempt++) try {
2406
- return await storage.current(journeyId);
2520
+ return await storage.preparationPermission(journeyId);
2407
2521
  } catch (error) {
2408
2522
  if (safeCode(error) !== "COORDINATION_BUSY" || attempt === 4) throw error;
2409
2523
  onFailure(error);
@@ -2476,6 +2590,16 @@ const preparationFailureBindingsSchema = z.object({
2476
2590
  workId: z.uuid(),
2477
2591
  inputRevision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
2478
2592
  }).strict();
2593
+ /** Issue #59: the named fields are exactly what siteFields keeps, so no other text passes. */
2594
+ const siteShape = {
2595
+ site: z.string().optional(),
2596
+ harness: z.string().optional(),
2597
+ harnessVersion: z.string().optional()
2598
+ };
2599
+ const onlySiteFields = (value) => {
2600
+ const kept = siteFields(value);
2601
+ return kept?.site === value.site && kept?.harness === value.harness && kept?.harnessVersion === value.harnessVersion;
2602
+ };
2479
2603
  const preparationSourceFailureSchema = z.object({
2480
2604
  location: z.object({
2481
2605
  workspaceId: z.uuid(),
@@ -2483,10 +2607,19 @@ const preparationSourceFailureSchema = z.object({
2483
2607
  sourcePartId: z.uuid().optional()
2484
2608
  }).strict(),
2485
2609
  rootSessionId: z.uuid().nullable(),
2486
- detail: artifactFailureDetailSchema.nullable()
2487
- }).strict().refine(({ location, rootSessionId, detail }) => (location.sessionId === void 0 ? rootSessionId === null && location.sourcePartId === void 0 : rootSessionId !== null) && (detail === null || location.sourcePartId !== void 0));
2610
+ detail: artifactFailureDetailSchema.nullable(),
2611
+ ...siteShape
2612
+ }).strict().refine(onlySiteFields).refine(({ location, rootSessionId, detail }) => (location.sessionId === void 0 ? rootSessionId === null && location.sourcePartId === void 0 : rootSessionId !== null) && (detail === null || location.sourcePartId !== void 0));
2613
+ /** One count per refusal group over every reported source, unlike the bounded list. */
2614
+ const preparationFailureCountSchema = z.object({
2615
+ code: z.string().regex(/^[A-Z_]{3,80}$/u).optional(),
2616
+ ...siteShape,
2617
+ count: z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
2618
+ }).strict().refine(onlySiteFields);
2488
2619
  const preparationFailureContextSchema = preparationFailureBindingsSchema.extend({
2489
2620
  sourceFailures: z.array(preparationSourceFailureSchema).max(16),
2621
+ sourceFailureCounts: z.array(preparationFailureCountSchema).max(32),
2622
+ /** The list or the counts left something out; the counts cover every source otherwise. */
2490
2623
  truncated: z.boolean()
2491
2624
  }).refine((context) => Buffer.byteLength(JSON.stringify(context)) <= maximumPreparationContextBytes);
2492
2625
  /** Invalid diagnostic input must never prevent the independent original-error write. */
@@ -2506,16 +2639,19 @@ function collectPreparationFailures(inventory, rootSessionIds, bindings) {
2506
2639
  let context;
2507
2640
  let families;
2508
2641
  const roots = new Set(rootSessionIds);
2642
+ const counted = [];
2509
2643
  try {
2510
2644
  const parsed = preparationFailureBindingsSchema.safeParse(bindings);
2511
2645
  if (parsed.success) context = {
2512
2646
  ...parsed.data,
2513
2647
  sourceFailures: [],
2648
+ sourceFailureCounts: [],
2514
2649
  truncated: false
2515
2650
  };
2516
2651
  } catch {}
2517
2652
  return {
2518
- report(location, detail) {
2653
+ /** `cause` contributes only its code and the fixed site fields (siteFields), never text. */
2654
+ report(location, detail, cause) {
2519
2655
  if (!context) return;
2520
2656
  try {
2521
2657
  const checked = validateFailureLocation(inventory, location);
@@ -2527,11 +2663,20 @@ function collectPreparationFailures(inventory, rootSessionIds, bindings) {
2527
2663
  sessionId: root.id
2528
2664
  }))) return;
2529
2665
  if (checked.sessionId === void 0 && !inventory.sessions.some((session) => session.workspaceId === checked.workspaceId && roots.has(families?.owners.get(session.id)?.id ?? ""))) return;
2666
+ const named = processingSite(cause);
2667
+ if (!counted.some((seen) => contains(seen, checked) || contains(checked, seen))) {
2668
+ counted.push(checked);
2669
+ context = countFailure(context, {
2670
+ ...cause === void 0 ? {} : { code: safeCode(cause) },
2671
+ ...named
2672
+ });
2673
+ }
2530
2674
  const facts = artifactFailureDetailSchema.safeParse(detail);
2531
2675
  const parsed = preparationSourceFailureSchema.safeParse({
2532
2676
  location: checked,
2533
2677
  rootSessionId: root?.id ?? null,
2534
- detail: facts.success ? facts.data : null
2678
+ detail: facts.success ? facts.data : null,
2679
+ ...named
2535
2680
  });
2536
2681
  if (!parsed.success) return;
2537
2682
  const entry = parsed.data;
@@ -2556,13 +2701,50 @@ function collectPreparationFailures(inventory, rootSessionIds, bindings) {
2556
2701
  }
2557
2702
  };
2558
2703
  }
2704
+ /** Adds one source to its (code, site, harness, version) group; a group past the bound truncates. */
2705
+ function countFailure(context, group) {
2706
+ const same = JSON.stringify({
2707
+ ...group,
2708
+ count: 0
2709
+ });
2710
+ const index = context.sourceFailureCounts.findIndex((entry) => JSON.stringify({
2711
+ ...entry,
2712
+ count: 0
2713
+ }) === same);
2714
+ if (index < 0 && context.sourceFailureCounts.length >= 32) return {
2715
+ ...context,
2716
+ truncated: true
2717
+ };
2718
+ const next = {
2719
+ ...context,
2720
+ sourceFailureCounts: index < 0 ? [...context.sourceFailureCounts, {
2721
+ ...group,
2722
+ count: 1
2723
+ }] : context.sourceFailureCounts.map((entry, at) => at === index ? {
2724
+ ...entry,
2725
+ count: entry.count + 1
2726
+ } : entry)
2727
+ };
2728
+ return readPreparationFailureContext(next) ? next : {
2729
+ ...context,
2730
+ truncated: true
2731
+ };
2732
+ }
2559
2733
  //#endregion
2560
2734
  //#region apps/cli/src/journey/prepare.ts
2735
+ const pendingSelected = (work) => work.status === "pending" && isSelectedWork(work);
2736
+ /** A retried Work is pending even if a previous generation produced a static receipt. */
2737
+ function initialScanWorks(state, scanRequested) {
2738
+ if (!scanRequested || state.scope.status !== "confirmed") return [];
2739
+ const selected = state.works.filter(isSelectedWork);
2740
+ if (!selected.some(pendingSelected)) return selected;
2741
+ return selected.filter((work) => work.status === "pending" || ["reading", "review"].includes(work.status) && !hasCurrentStaticReview(state, work));
2742
+ }
2561
2743
  function createJourneyPreparation(options) {
2562
2744
  return async (journeyId, signal) => {
2563
2745
  const { storage } = options;
2564
2746
  if (signal?.aborted) throw new ApplicationError("OPERATION_CANCELED");
2565
- const current = await storage.current(journeyId);
2747
+ const current = await storage.preparationState(journeyId);
2566
2748
  if (!current?.consent) throw new ApplicationError("CONFIRMATION_REQUIRED");
2567
2749
  if (obsoleteJourney(current)) throw new ApplicationError("JOURNEY_OBSOLETE");
2568
2750
  if (current.phase === "paused") throw new ApplicationError("OPERATION_CANCELED");
@@ -2585,10 +2767,11 @@ function createJourneyPreparation(options) {
2585
2767
  terms: []
2586
2768
  };
2587
2769
  const plan = await prepareSourcePlan(storage, options.harnesses, journeyId, terms, abort.signal, current.period, options.stateDir);
2588
- const initial = await storage.current(journeyId);
2770
+ const initial = await storage.preparationState(journeyId);
2589
2771
  if (!initial?.consent) throw new ApplicationError("CONFIRMATION_REQUIRED");
2590
2772
  const formed = initial.snapshotId ? [] : namedJourneyWorks(formJourneyWorks(plan.approval, options.processing.environment), options.processing, abort.signal);
2591
- const state = await changeJourney(storage, journeyId, "snapshot", (before) => ({
2773
+ let state;
2774
+ if (!initial.snapshotId) state = await changeJourney(storage, journeyId, "snapshot", (before) => ({
2592
2775
  ...before,
2593
2776
  snapshotId: before.snapshotId ?? textDigest(JSON.stringify(plan)),
2594
2777
  ...before.snapshotId ? {} : {
@@ -2596,17 +2779,22 @@ function createJourneyPreparation(options) {
2596
2779
  reviewMethod: "dictionary"
2597
2780
  },
2598
2781
  works: before.snapshotId ? before.works : formed,
2599
- phase: before.phase === "paused" ? "paused" : !before.snapshotId || before.works.some((work) => work.status === "pending" && isSelectedWork(work)) ? "processing" : before.phase
2782
+ phase: before.phase !== "paused" && (!before.snapshotId || before.works.some(pendingSelected)) ? "processing" : before.phase
2600
2783
  }));
2784
+ else if (initial.phase !== "processing" && initial.phase !== "paused" && initial.works.some(pendingSelected)) state = await changePreparation(storage, journeyId, "snapshot", (before) => before.phase !== "paused" && before.works.some(pendingSelected) ? {
2785
+ ...before,
2786
+ phase: "processing"
2787
+ } : before);
2788
+ else state = initial;
2601
2789
  if (!state.works.length) throw new ApplicationError("INVALID_SELECTION");
2602
2790
  if (!scanRequested) await captureSortingScope(storage, journeyId, workApproval(plan.approval, state.works.flatMap((work) => work.rootSessionIds)), directory, abort.signal);
2603
- const scanWorks = scanRequested && state.scope.status === "confirmed" ? state.works : [];
2791
+ const scanWorks = initialScanWorks(state, scanRequested);
2604
2792
  if (scanWorks.length) run = await runWorkers(options.workers);
2605
2793
  const frozen = state;
2606
2794
  const blockedForRetry = new Set(state.works.filter((work) => work.status === "blocked").map((work) => `${work.id}:${work.inputRevision}`));
2607
2795
  const deferredForHide = /* @__PURE__ */ new Set();
2608
2796
  const blockWork = async (work, error) => {
2609
- const blocked = await changeJourney(storage, journeyId, "snapshot", (before) => {
2797
+ const blocked = await changePreparation(storage, journeyId, "snapshot", (before) => {
2610
2798
  if (before.phase === "paused") throw new ApplicationError("OPERATION_CANCELED");
2611
2799
  if (before.phase === "sending" || before.phase === "completed") return before;
2612
2800
  return withReadingProgress({
@@ -2645,7 +2833,7 @@ function createJourneyPreparation(options) {
2645
2833
  abort.signal.throwIfAborted();
2646
2834
  let resumed = false;
2647
2835
  let deferred = false;
2648
- const committed = await changeJourney(storage, journeyId, "snapshot", (before) => {
2836
+ const committed = await changePreparation(storage, journeyId, "snapshot", (before) => {
2649
2837
  resumed = false;
2650
2838
  deferred = false;
2651
2839
  const target = before.works.find((item) => item.id === work.id);
@@ -2654,17 +2842,13 @@ function createJourneyPreparation(options) {
2654
2842
  deferred = !resumed;
2655
2843
  return withReadingProgress({
2656
2844
  ...before,
2657
- privacy: {
2658
- ...before.privacy,
2659
- packageApproval: null
2660
- },
2661
2845
  works: before.works.map((item) => item.id === work.id ? {
2662
2846
  ...item,
2663
2847
  status: resumed ? work.status : "pending",
2664
2848
  blockedCodes: []
2665
2849
  } : item)
2666
2850
  });
2667
- }, 100);
2851
+ }, 100, true);
2668
2852
  if (deferred && committed.pendingHideId && committed.works.some((item) => generation(item) === generation(work) && item.status === "pending")) deferredForHide.add(generation(work));
2669
2853
  return resumed;
2670
2854
  };
@@ -2684,7 +2868,7 @@ function createJourneyPreparation(options) {
2684
2868
  };
2685
2869
  /** D2: the person dropped this Work while it was prepared; the other Works go on. */
2686
2870
  const droppedMidScan = async (workId) => {
2687
- const now = await storage.current(journeyId);
2871
+ const now = await storage.preparationState(journeyId);
2688
2872
  const work = now?.works.find((item) => item.id === workId);
2689
2873
  return Boolean(now && now.phase !== "paused" && now.scope.status === "confirmed" && work && !isSelectedWork(work));
2690
2874
  };
@@ -2693,7 +2877,7 @@ function createJourneyPreparation(options) {
2693
2877
  /** True when failing roots left the Work, which is then prepared again (ADR 0188). */
2694
2878
  const prepareWorkAttempt = async (planned, onSubmitted, releaseRead) => {
2695
2879
  abort.signal.throwIfAborted();
2696
- const latest = await storage.current(journeyId) ?? frozen;
2880
+ const latest = await storage.preparationState(journeyId) ?? frozen;
2697
2881
  abort.signal.throwIfAborted();
2698
2882
  if (latest.phase === "paused") throw new ApplicationError("OPERATION_CANCELED");
2699
2883
  let work = latest.works.find((item) => item.id === planned.id);
@@ -2717,7 +2901,7 @@ function createJourneyPreparation(options) {
2717
2901
  signal: abort.signal,
2718
2902
  onTemporary: async () => {},
2719
2903
  onPhase: async () => {},
2720
- onFailureLocation: (location) => failures.report(location),
2904
+ onFailureLocation: (location, _detail, cause) => failures.report(location, void 0, cause),
2721
2905
  onTiming: (timing) => options.onTiming?.(planned.id, timing)
2722
2906
  }, run.workers, (revised) => {
2723
2907
  work = revised;
@@ -2745,7 +2929,7 @@ function createJourneyPreparation(options) {
2745
2929
  readRetries.set(generation(work), attempt + 1);
2746
2930
  return prepareWorkOnce(planned, onSubmitted);
2747
2931
  }
2748
- const now = await storage.current(journeyId);
2932
+ const now = await storage.preparationState(journeyId);
2749
2933
  if (now?.phase === "sending" || now?.phase === "completed") return false;
2750
2934
  if (blockedCodes(error, code).includes("PRIVACY_CHANGE_PENDING")) {
2751
2935
  if (work) await blockWork(work, error);
@@ -2775,7 +2959,7 @@ function createJourneyPreparation(options) {
2775
2959
  const staticWork = async (planned, passSubmitted) => {
2776
2960
  await passSubmitted;
2777
2961
  abort.signal.throwIfAborted();
2778
- const latest = await storage.current(journeyId);
2962
+ const latest = await storage.preparationState(journeyId);
2779
2963
  abort.signal.throwIfAborted();
2780
2964
  if (!latest?.consent || latest.phase === "paused" || latest.scope.status !== "confirmed") throw new ApplicationError("OPERATION_CANCELED");
2781
2965
  const work = latest.works.find((item) => item.id === planned.id);
@@ -2803,7 +2987,7 @@ function createJourneyPreparation(options) {
2803
2987
  staticRetries.set(generation(work), attempt + 1);
2804
2988
  return staticWork(planned, passSubmitted);
2805
2989
  }
2806
- const now = await storage.current(journeyId);
2990
+ const now = await storage.preparationState(journeyId);
2807
2991
  if (now?.phase !== "sending" && now?.phase !== "completed" && (attempt >= retryLimit || blockedCodes(error, safeCode(error)).includes("PRIVACY_CHANGE_PENDING"))) await blockWork(work, error);
2808
2992
  }
2809
2993
  };
@@ -2881,7 +3065,7 @@ function createJourneyPreparation(options) {
2881
3065
  for (;;) {
2882
3066
  abort.signal.throwIfAborted();
2883
3067
  const settled = workSettled.promise;
2884
- const latest = await storage.current(journeyId);
3068
+ const latest = await storage.preparationState(journeyId);
2885
3069
  if (!latest || latest.phase === "paused" || latest.scope.status !== "confirmed") return [];
2886
3070
  for (const work of latest.works) if (work.status === "blocked") blockedForRetry.add(generation(work));
2887
3071
  const ready = latest.works.filter((work) => isSelectedWork(work) && [
@@ -2902,7 +3086,8 @@ function createJourneyPreparation(options) {
2902
3086
  }
2903
3087
  };
2904
3088
  try {
2905
- for (let batch = scanWorks; batch.length && !fatal; batch = await later()) {
3089
+ const firstBatch = scanRequested && !scanWorks.length && isClassifying(state) ? await later() : scanWorks;
3090
+ for (let batch = firstBatch; batch.length && !fatal; batch = await later()) {
2906
3091
  if (!run.workers && !run.pool) run = await runWorkers(options.workers);
2907
3092
  await pass(batch);
2908
3093
  }
@@ -2921,7 +3106,7 @@ function createJourneyPreparation(options) {
2921
3106
  });
2922
3107
  if (fatal) throw fatal.error;
2923
3108
  abort.signal.throwIfAborted();
2924
- const result = await storage.current(journeyId);
3109
+ const result = await storage.preparationState(journeyId);
2925
3110
  await stopObserving();
2926
3111
  abort.signal.throwIfAborted();
2927
3112
  if (!result?.consent || result.phase === "paused" || scanRequested && result.scope.status !== "confirmed") throw new ApplicationError("OPERATION_CANCELED");
@@ -3059,14 +3244,15 @@ async function readJourneyPlan(storage, journeyId) {
3059
3244
  async function finalKeywordDictionary(storage, state, sessionIds, plan) {
3060
3245
  if (!state.snapshotId) throw new ApplicationError("CONFIRMATION_REQUIRED");
3061
3246
  const dictionary = /* @__PURE__ */ new Map();
3062
- const add = (term, kind, bounded) => {
3247
+ const add = (term, kind, bounded, toolResultEscapes = false) => {
3063
3248
  const prior = dictionary.get(term);
3064
3249
  dictionary.set(term, {
3065
3250
  kind: prior && confirmedHideKinds.indexOf(prior.kind) > confirmedHideKinds.indexOf(kind) ? prior.kind : kind,
3066
- bounded: bounded && (prior?.bounded ?? true)
3251
+ bounded: bounded && (prior?.bounded ?? true),
3252
+ ...toolResultEscapes || prior?.toolResultEscapes ? { toolResultEscapes: true } : {}
3067
3253
  });
3068
3254
  };
3069
- for (const [term, { kind }] of await journeyConfirmedHides(storage, state)) add(term, kind, true);
3255
+ for (const [term, { kind }] of await journeyConfirmedHides(storage, state)) add(term, kind, true, true);
3070
3256
  for (const value of (await activeJourneyTerms(storage, state)).terms) {
3071
3257
  const admitted = admitCustomTerm(value);
3072
3258
  if (admitted) add(admitted.value, "private", false);
@@ -3172,10 +3358,11 @@ function createFamilyHides() {
3172
3358
  families.set(sessionId, terms);
3173
3359
  }
3174
3360
  const members = (root, role) => sessions.filter((session) => session.role === role && session.rootSessionId === root).map((session) => session.archiveSessionId);
3175
- const all = [...confirmed].map(([term, { kind, bounded }]) => ({
3361
+ const all = [...confirmed].map(([term, { kind, bounded, toolResultEscapes }]) => ({
3176
3362
  term,
3177
3363
  category: kind,
3178
- ...bounded ? { bounded: true } : {}
3364
+ ...bounded ? { bounded: true } : {},
3365
+ ...bounded && toolResultEscapes ? { toolResultEscapes: true } : {}
3179
3366
  }));
3180
3367
  return [...families].flatMap(([root, terms]) => {
3181
3368
  const auto = terms.size ? all.filter(({ term }) => !terms.has(term)) : all;
@@ -3433,8 +3620,10 @@ function createRenderWorkers(workers, size) {
3433
3620
  if (result.kind !== "journey-base") throw new Error("ARTIFACT_PROCESSING_FAILED");
3434
3621
  return result.base;
3435
3622
  } catch (error) {
3436
- await removeNamedTemporarySpools(named).catch((cleanup) => workers.onFallback?.(cleanup));
3437
- if (error instanceof SessionWorkerError) throw new ApplicationError(error.message, void 0, { cause: error });
3623
+ if (error instanceof SessionWorkerError) {
3624
+ await removeNamedTemporarySpools(named).catch((cleanup) => workers.onFallback?.(cleanup));
3625
+ throw new ApplicationError(error.message, void 0, { cause: error });
3626
+ }
3438
3627
  if (signal.aborted) throw error;
3439
3628
  workers.onFallback?.(error);
3440
3629
  return pool.serial(() => writeJourneyBase(work, signal));
@@ -3677,6 +3866,7 @@ function createJourneyRenderer(options) {
3677
3866
  async function admit(workId, session) {
3678
3867
  const state = await session.current();
3679
3868
  if (!state?.consent) throw new ApplicationError("CONFIRMATION_REQUIRED");
3869
+ if (state.phase === "completed" && state.outputVersion !== 1) throw new ApplicationError("SCOPE_NOT_READY");
3680
3870
  assertJourneyPrivacyReady(state, true);
3681
3871
  if (obsoleteJourney(state)) throw new ApplicationError("JOURNEY_OBSOLETE");
3682
3872
  if (state.pendingHideId) throw new ApplicationError("SCOPE_NOT_READY");
@@ -4685,6 +4875,22 @@ async function resolveChunkSessions(storage, state, works, signal) {
4685
4875
  }
4686
4876
  //#endregion
4687
4877
  //#region apps/cli/src/journey/works/proposal.ts
4878
+ /** Materialize exactly the Work families accepted by classification, preserving first-name spelling. */
4879
+ function groupedClassificationItems(items) {
4880
+ const merged = /* @__PURE__ */ new Map();
4881
+ for (const item of items) {
4882
+ const key = projectNameKey(item.projectName);
4883
+ const prior = merged.get(key);
4884
+ if (prior) {
4885
+ if (prior.description !== item.description || prior.projectType !== item.projectType || JSON.stringify(prior.recommendation) !== JSON.stringify(item.recommendation)) throw new ApplicationError("INVALID_SELECTION");
4886
+ merged.set(key, {
4887
+ ...prior,
4888
+ rootSessionIds: [...prior.rootSessionIds, ...item.rootSessionIds]
4889
+ });
4890
+ } else merged.set(key, item);
4891
+ }
4892
+ return [...merged.values()];
4893
+ }
4688
4894
  /**
4689
4895
  * One atomic root partition; recommendations have no access authority. Roots still being grouped
4690
4896
  * wait in one unread Work while the other Works are confirmed and read; the submission that
@@ -4724,21 +4930,9 @@ function createClassificationProposal(storage, accountAccess) {
4724
4930
  if (plan?.kind !== "plan") throw new ApplicationError("SCOPE_NOT_READY");
4725
4931
  const roots = new Set(state.works.flatMap((work) => work.rootSessionIds));
4726
4932
  const seen = /* @__PURE__ */ new Set();
4727
- const merged = /* @__PURE__ */ new Map();
4728
- for (const item of input.items) {
4729
- for (const root of item.rootSessionIds) {
4730
- if (!roots.has(root) || seen.has(root)) throw new ApplicationError("INVALID_SELECTION");
4731
- seen.add(root);
4732
- }
4733
- const key = projectNameKey(item.projectName);
4734
- const prior = merged.get(key);
4735
- if (prior) {
4736
- if (prior.description !== item.description || prior.projectType !== item.projectType || JSON.stringify(prior.recommendation) !== JSON.stringify(item.recommendation)) throw new ApplicationError("INVALID_SELECTION");
4737
- merged.set(key, {
4738
- ...prior,
4739
- rootSessionIds: [...prior.rootSessionIds, ...item.rootSessionIds]
4740
- });
4741
- } else merged.set(key, item);
4933
+ for (const item of input.items) for (const root of item.rootSessionIds) {
4934
+ if (!roots.has(root) || seen.has(root)) throw new ApplicationError("INVALID_SELECTION");
4935
+ seen.add(root);
4742
4936
  }
4743
4937
  const pending = input.pendingRootSessionIds ?? [];
4744
4938
  for (const root of pending) {
@@ -4758,7 +4952,7 @@ function createClassificationProposal(storage, accountAccess) {
4758
4952
  return [...new Set(sessions.map((session) => session.workspaceId))];
4759
4953
  };
4760
4954
  const items = [];
4761
- for (const item of merged.values()) {
4955
+ for (const item of groupedClassificationItems(input.items)) {
4762
4956
  signal.throwIfAborted();
4763
4957
  items.push({
4764
4958
  ...item,
@@ -4786,14 +4980,114 @@ function createClassificationProposal(storage, accountAccess) {
4786
4980
  };
4787
4981
  const { works } = classificationWorks(state, prepared);
4788
4982
  const chunkSessions = await resolveChunkSessions(storage, state, works, signal);
4789
- const access = late ? await accountAccess?.({
4983
+ const read = late ? await accountAccess?.({
4790
4984
  ...state,
4791
4985
  works
4792
4986
  }, signal) : void 0;
4987
+ const sameAccount = read?.status === "read" && state.accountRead?.status === "read" && state.accountRead.subject === read.subject;
4793
4988
  return storage.commitClassification({
4794
4989
  ...prepared,
4795
4990
  chunkSessions,
4796
- ...access ? { accountAccess: Object.fromEntries(access) } : {}
4991
+ ...sameAccount ? {
4992
+ accountAccess: Object.fromEntries(read.access),
4993
+ accountSubject: read.subject
4994
+ } : read ? { accountRead: read.status === "read" ? "unavailable" : read.status } : {}
4995
+ });
4996
+ };
4997
+ }
4998
+ //#endregion
4999
+ //#region apps/cli/src/journey/works/recover-isolated.ts
5000
+ /** Match the entire original family partition, including other isolated siblings of this parent. */
5001
+ function approvedItem(state, child, items) {
5002
+ const matches = items.filter((item) => child.rootSessionIds.every((id) => item.rootSessionIds.includes(id)));
5003
+ const item = matches[0];
5004
+ if (matches.length !== 1 || !item) throw new ApplicationError("INVALID_SELECTION");
5005
+ const parents = state.works.filter((work) => work.projectName === item.projectName && work.description === item.description && work.classificationRecommendation?.access === item.recommendation.access && work.classificationRecommendation.reason === item.recommendation.reason);
5006
+ const parent = parents[0];
5007
+ if (parents.length !== 1 || !parent) throw new ApplicationError("INVALID_SELECTION");
5008
+ const siblingIds = new Set(Array.from({ length: state.works.length + 1 }, (_, i) => isolatedWorkId(parent.id, i)));
5009
+ if (!siblingIds.has(child.id)) throw new ApplicationError("INVALID_SELECTION");
5010
+ const roots = state.works.filter((w) => w.id === parent.id || siblingIds.has(w.id)).flatMap((w) => w.rootSessionIds);
5011
+ if (roots.length !== item.rootSessionIds.length || new Set(roots).size !== roots.length || !roots.every((root) => item.rootSessionIds.includes(root))) throw new ApplicationError("INVALID_SELECTION");
5012
+ return item;
5013
+ }
5014
+ /** Repair the missing classification only, using its immutable receipt and the ordinary static metadata policy. */
5015
+ function createIsolatedMetadataRecovery(storage, processing) {
5016
+ return async (input, signal) => {
5017
+ const { proposal, workIds, idempotencyKey } = input;
5018
+ const journeyId = proposal.journeyId;
5019
+ const hash = textDigest(JSON.stringify(input));
5020
+ const state = await storage.current();
5021
+ if (!state || state.journeyId !== journeyId) throw new ApplicationError("UNKNOWN_ITEM");
5022
+ if (await storage.receiptRevision({
5023
+ journeyId,
5024
+ key: idempotencyKey,
5025
+ hash
5026
+ }) !== null) return {
5027
+ status: "replayed",
5028
+ state
5029
+ };
5030
+ const accepted = await storage.receiptRevision({
5031
+ journeyId,
5032
+ key: proposal.idempotencyKey,
5033
+ hash: textDigest(JSON.stringify(proposal))
5034
+ });
5035
+ if (accepted === null || accepted !== state.classificationRevision) throw new ApplicationError("REVISION_CONFLICT");
5036
+ if (!state.consent || state.scope.status !== "confirmed" || state.abandonedAt || !state.readingComplete || state.pendingHideId || !["ready", "review"].includes(state.phase)) throw new ApplicationError("SCOPE_NOT_READY");
5037
+ if (new Set(workIds).size !== workIds.length || proposal.pendingRootSessionIds?.length) throw new ApplicationError("INVALID_SELECTION");
5038
+ const plan = (await storage.resources({
5039
+ journeyId,
5040
+ kind: "plan",
5041
+ id: "plan"
5042
+ }))[0];
5043
+ if (plan?.kind !== "plan") throw new ApplicationError("SCOPE_NOT_READY");
5044
+ const recovered = /* @__PURE__ */ new Map();
5045
+ const families = groupedClassificationItems(proposal.items);
5046
+ for (const id of workIds) {
5047
+ signal.throwIfAborted();
5048
+ const work = state.works.find((w) => w.id === id);
5049
+ if (!work || !work.createdByPlacement || work.placement !== "placed" || work.description || work.classificationRecommendation || work.suggestion || work.explicitTitleId || work.titleOrigin !== "model" || !work.rootSessionIds.length || !work.accessConfirmed || work.access === "drop" || work.status !== "review" || !work.artifact || work.artifact.revision !== work.revision || work.completedBatches !== work.totalBatches) throw new ApplicationError("INVALID_SELECTION");
5050
+ const item = approvedItem(state, work, families);
5051
+ const restored = {
5052
+ ...work,
5053
+ description: item.description,
5054
+ classificationRecommendation: item.recommendation,
5055
+ reason: item.recommendation.reason,
5056
+ projectType: work.projectType ?? {
5057
+ type: item.projectType,
5058
+ reason: item.recommendation.reason,
5059
+ origin: "model"
5060
+ },
5061
+ artifact: null,
5062
+ revision: work.revision + 1
5063
+ };
5064
+ const metadata = await processedClassificationMetadata(storage, state, restored, processing, signal, plan);
5065
+ if (!metadata || !hasCompleteWorkDescription(restored)) throw new ApplicationError("INVALID_SELECTION");
5066
+ recovered.set(id, {
5067
+ ...restored,
5068
+ classificationMetadata: metadata
5069
+ });
5070
+ }
5071
+ signal.throwIfAborted();
5072
+ return storage.commit({
5073
+ expectedRevision: state.revision,
5074
+ requestKey: idempotencyKey,
5075
+ requestHash: hash,
5076
+ state: {
5077
+ ...state,
5078
+ phase: "review",
5079
+ works: state.works.map((w) => recovered.get(w.id) ?? w),
5080
+ privacy: {
5081
+ ...state.privacy,
5082
+ packageApproval: null
5083
+ }
5084
+ },
5085
+ event: {
5086
+ journeyId,
5087
+ kind: "snapshot",
5088
+ source: "system",
5089
+ at: (/* @__PURE__ */ new Date()).toISOString()
5090
+ }
4797
5091
  });
4798
5092
  };
4799
5093
  }
@@ -5141,6 +5435,7 @@ function createJourneyClassification(storage, _processing) {
5141
5435
  /** The journey local port's project catalog and project-type suggestions. */
5142
5436
  function createJourneyWorks(storage, processing, stateDir, accountAccess) {
5143
5437
  return {
5438
+ recoverIsolatedMetadata: createIsolatedMetadataRecovery(storage, processing),
5144
5439
  classificationSessions: createClassificationSessions(storage, processing, stateDir),
5145
5440
  classificationRead: createClassificationReader(storage, stateDir),
5146
5441
  classificationSubmit: createClassificationProposal(storage, accountAccess),
@@ -5259,6 +5554,8 @@ const packagingRefusalCodes = [
5259
5554
  */
5260
5555
  const finalPackagingCodes = [
5261
5556
  ...packagingRefusalCodes,
5557
+ "WORK_METADATA_INCOMPLETE",
5558
+ "PACKAGING_FAILED",
5262
5559
  ...rootScanRefusals,
5263
5560
  "ARTIFACT_ARCHIVE_LIMIT",
5264
5561
  "ARTIFACT_ENTRY_LIMIT",
@@ -5294,22 +5591,45 @@ function createSendPackager(ports) {
5294
5591
  current = await ports.current(journeyId);
5295
5592
  if (!current) throw new ApplicationError("UNKNOWN_ITEM");
5296
5593
  }
5297
- const ready = (works, id) => works.some((work) => work.id === id && packaged(work));
5298
- const batch = workIds.filter((id) => !ready(current.works, id));
5594
+ const ready = new Set(current.works.filter(packaged).map((work) => work.id));
5595
+ const batch = workIds.filter((id) => !ready.has(id));
5299
5596
  if (!batch.length) return true;
5597
+ const selected = new Set(batch);
5598
+ if (current.works.some((work) => selected.has(work.id) && !hasCompleteWorkDescription(work))) throw new ApplicationError("WORK_METADATA_INCOMPLETE");
5300
5599
  signal.throwIfAborted();
5301
5600
  const result = await ports.render({
5302
5601
  journeyId,
5303
5602
  workIds: batch
5304
5603
  }, signal);
5305
- const failures = result.renders?.filter((item) => item.status === "failed") ?? [];
5306
- const failure = failures.find((item) => packagingRefusalCodes.includes(item.code)) ?? failures.find((item) => finalPackagingCodes.includes(item.code)) ?? failures[0];
5307
- if (failure) throw new ApplicationError(failure.code);
5308
- if (batch.some((id) => !ready(result.works, id))) throw new Error("PACKAGING_FAILED");
5604
+ const works = new Map(result.works.map((work) => [work.id, work]));
5605
+ const failures = new Map(result.renders?.filter((item) => item.status === "failed").map((item) => [item.workId, item.code]));
5606
+ for (const id of batch) {
5607
+ const work = works.get(id);
5608
+ if ((!work || !packaged(work)) && !failures.has(id)) failures.set(id, unfinishedPackagingCode(work));
5609
+ }
5610
+ const codes = [...failures.values()];
5611
+ const failure = codes.find((code) => packagingRefusalCodes.includes(code)) ?? codes.find((code) => finalPackagingCodes.includes(code)) ?? codes[0];
5612
+ if (failure) throw new ApplicationError(failure);
5309
5613
  return false;
5310
5614
  };
5311
5615
  }
5312
5616
  //#endregion
5617
+ //#region apps/cli/src/package-identity.ts
5618
+ /** Release identity stays independent of producer/processing format versions. */
5619
+ const packageIdentity = Object.freeze(z.object({
5620
+ name: z.literal("@gitroll/tacitt-cli"),
5621
+ version: z.string().regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/)
5622
+ }).parse(JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"))));
5623
+ //#endregion
5624
+ //#region apps/cli/skills/tacitt/scripts/journey-panel-host.mjs
5625
+ /** Only the host's canonical browser bootstrap may be handed to a fresh browser. */
5626
+ function focusedPanelUrl(value, journeyId) {
5627
+ if (typeof value !== "string") return null;
5628
+ const url = URL.parse(value);
5629
+ if (!url || url.href !== value || url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.username || url.password || !/^\/session-[a-f0-9]{16}\/$/u.test(url.pathname) || url.searchParams.size !== 2 || url.searchParams.get("view") !== "skill" || url.searchParams.get("journey") !== journeyId || !/^#token=[A-Za-z0-9_-]{43}$/u.test(url.hash)) return null;
5630
+ return url;
5631
+ }
5632
+ //#endregion
5313
5633
  //#region apps/cli/src/serve-process.ts
5314
5634
  const execute = promisify(execFile);
5315
5635
  /** OS process creation identity, independent of PID recycling. */
@@ -5374,8 +5694,14 @@ const recordSchema$1 = z.object({
5374
5694
  journeyId: z.string(),
5375
5695
  pid: z.number().int().positive(),
5376
5696
  log: z.string(),
5697
+ version: z.string().optional(),
5698
+ currentKeepAuthority: z.boolean().optional(),
5699
+ outputVersion: z.number().int().optional(),
5377
5700
  processStart: z.string().optional()
5378
5701
  });
5702
+ function enforcesPrivacy(record) {
5703
+ return record?.currentKeepAuthority && record.outputVersion === 1;
5704
+ }
5379
5705
  const recordPath$1 = (directory, journeyId) => join(directory, `${journeyId}.host.json`);
5380
5706
  function running(pid) {
5381
5707
  try {
@@ -5386,19 +5712,18 @@ function running(pid) {
5386
5712
  }
5387
5713
  }
5388
5714
  /** Only verified absence admits a replacement; transport failure is inconclusive. */
5389
- async function liveHost(directory, journeyId) {
5715
+ async function liveHost(directory, journeyId, strictRecord = false) {
5390
5716
  let record;
5391
5717
  try {
5392
5718
  const parsed = recordSchema$1.safeParse(JSON.parse(await readFile(recordPath$1(directory, journeyId), "utf8")));
5393
- if (!parsed.success || parsed.data.journeyId !== journeyId) return { status: "dead" };
5719
+ if (!parsed.success || parsed.data.journeyId !== journeyId) return { status: strictRecord ? "unknown" : "dead" };
5394
5720
  record = parsed.data;
5395
5721
  } catch (error) {
5396
- if (error instanceof SyntaxError || error instanceof Error && "code" in error && error.code === "ENOENT") return { status: "dead" };
5722
+ if (!strictRecord && error instanceof SyntaxError || error instanceof Error && "code" in error && error.code === "ENOENT") return { status: "dead" };
5397
5723
  process.stderr.write("HOST_RECORD_UNAVAILABLE\n");
5398
5724
  return { status: "unknown" };
5399
5725
  }
5400
- const state = running(record.pid);
5401
- if (state === "dead") return { status: "dead" };
5726
+ if (running(record.pid) === "dead") return { status: "dead" };
5402
5727
  if (record.processStart !== void 0) try {
5403
5728
  if (await processStart(record.pid) !== record.processStart) return { status: "dead" };
5404
5729
  } catch {
@@ -5408,19 +5733,37 @@ async function liveHost(directory, journeyId) {
5408
5733
  record
5409
5734
  };
5410
5735
  }
5411
- else if (state === "unknown") return { status: "dead" };
5412
5736
  if (!record.url) return {
5413
5737
  status: "unknown",
5414
5738
  record
5415
5739
  };
5740
+ if (!record.processStart) return {
5741
+ status: "unknown",
5742
+ record
5743
+ };
5744
+ if (record.version !== packageIdentity.version || !enforcesPrivacy(record)) return {
5745
+ status: "obsolete",
5746
+ record
5747
+ };
5748
+ const url = focusedPanelUrl(record.url, journeyId);
5749
+ if (!url) return {
5750
+ status: "obsolete",
5751
+ record
5752
+ };
5753
+ url.hash = "";
5416
5754
  try {
5417
- await fetch(record.url, { signal: AbortSignal.timeout(2e3) });
5418
- return {
5755
+ return (await fetch(url, {
5756
+ method: "HEAD",
5757
+ redirect: "manual",
5758
+ signal: AbortSignal.timeout(2e3)
5759
+ })).ok ? {
5419
5760
  status: "alive",
5420
5761
  record
5762
+ } : {
5763
+ status: "unknown",
5764
+ record
5421
5765
  };
5422
- } catch (error) {
5423
- if (record.processStart === void 0 && loopbackRefused(record.url, error)) return { status: "dead" };
5766
+ } catch {
5424
5767
  process.stderr.write("HOST_PROBE_UNAVAILABLE\n");
5425
5768
  return {
5426
5769
  status: "unknown",
@@ -5428,16 +5771,57 @@ async function liveHost(directory, journeyId) {
5428
5771
  };
5429
5772
  }
5430
5773
  }
5431
- function loopbackRefused(url, error) {
5432
- const host = URL.parse(url)?.hostname;
5433
- if (host !== "127.0.0.1" && host !== "[::1]" && host !== "localhost") return false;
5434
- if (!(error instanceof Error)) return false;
5435
- if ("code" in error && error.code === "ECONNREFUSED") return true;
5436
- if (error instanceof AggregateError) return error.errors.length > 0 && error.errors.every((item) => loopbackRefused(url, item));
5437
- return loopbackRefused(url, error.cause);
5774
+ /** Called only while the launch mutex is held. Never start alongside an old owner. */
5775
+ async function relinquish(record) {
5776
+ const sameOwner = async () => running(record.pid) !== "dead" && await processStart(record.pid) === record.processStart;
5777
+ try {
5778
+ if (!await sameOwner()) return true;
5779
+ process.kill(record.pid, "SIGTERM");
5780
+ const deadline = Date.now() + 1e4;
5781
+ while (Date.now() < deadline) {
5782
+ if (!await sameOwner()) return true;
5783
+ await setTimeout$1(100);
5784
+ }
5785
+ } catch {
5786
+ if (running(record.pid) === "dead") return true;
5787
+ }
5788
+ process.stderr.write("HOST_REPLACEMENT_PENDING: the previous panel host has not confirmed shutdown. Retry the handoff.\n");
5789
+ return false;
5790
+ }
5791
+ /** A previous sender cannot observe a new policy by reusing its cached approval.
5792
+ * Only a live, PID-and-birth-verified owner may be signalled. */
5793
+ async function fenceUnapprovedSendHost(stateDir, journey) {
5794
+ if (journey?.phase !== "sending" || journey.privacy.packageApproval !== null) return;
5795
+ const directory = join(stateDir, "serve");
5796
+ const status = await liveHost(directory, journey.journeyId, true);
5797
+ if (status.status === "dead" || enforcesPrivacy(status.record)) return;
5798
+ await withStateMutex(directory, async () => {
5799
+ const current = await liveHost(directory, journey.journeyId, true);
5800
+ if (current.status === "dead" || enforcesPrivacy(current.record)) return;
5801
+ if (current.status !== "obsolete" || !await relinquish(current.record)) throw new ApplicationError("HOST_REPLACEMENT_PENDING");
5802
+ });
5803
+ }
5804
+ /** Best-effort cleanup before an obsolete journey can lose its current ID. */
5805
+ async function relinquishObsoleteHost(stateDir, journeyId) {
5806
+ const directory = join(stateDir, "serve");
5807
+ try {
5808
+ await readFile(recordPath$1(directory, journeyId), "utf8");
5809
+ await withStateMutex(directory, async () => {
5810
+ const current = await liveHost(directory, journeyId);
5811
+ if (current.status === "obsolete" && current.record.version !== packageIdentity.version) await relinquish(current.record);
5812
+ });
5813
+ } catch (error) {
5814
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
5815
+ process.stderr.write("HOST_REPLACEMENT_PENDING: obsolete journey host cleanup failed.\n");
5816
+ }
5438
5817
  }
5439
5818
  function reuse(existing) {
5440
5819
  if (existing.status === "dead") return false;
5820
+ if (existing.status === "obsolete") {
5821
+ process.stderr.write("HOST_REPLACEMENT_PENDING: retry the handoff.\n");
5822
+ process.exitCode = 1;
5823
+ return true;
5824
+ }
5441
5825
  if (existing.status === "alive") process.stdout.write(`${JSON.stringify({
5442
5826
  ok: true,
5443
5827
  event: "ready",
@@ -5499,7 +5883,12 @@ async function serveDetached(args, stateDir, journeyId) {
5499
5883
  recursive: true,
5500
5884
  force: true
5501
5885
  });
5502
- if (reuse(current)) return;
5886
+ if (current.status === "obsolete") {
5887
+ if (!await relinquish(current.record)) {
5888
+ process.exitCode = 1;
5889
+ return;
5890
+ }
5891
+ } else if (reuse(current)) return;
5503
5892
  let retries = 0;
5504
5893
  let delay = 500;
5505
5894
  for (;;) {
@@ -5563,6 +5952,11 @@ async function startHost(args, directory, journeyId, deadline) {
5563
5952
  await output.close();
5564
5953
  throw error;
5565
5954
  }
5955
+ let ready;
5956
+ child.on("message", (message) => {
5957
+ const parsed = readySchema.safeParse(message);
5958
+ if (parsed.success) ready ??= parsed.data.result;
5959
+ });
5566
5960
  let exited = null;
5567
5961
  child.once("exit", (code) => exited = code ?? 1);
5568
5962
  let spawnFailure;
@@ -5578,6 +5972,9 @@ async function startHost(args, directory, journeyId, deadline) {
5578
5972
  identity = await processStart(child.pid);
5579
5973
  await publishRecord(directory, id, {
5580
5974
  url: "",
5975
+ version: packageIdentity.version,
5976
+ currentKeepAuthority: true,
5977
+ outputVersion: 1,
5581
5978
  journeyId,
5582
5979
  pid: child.pid,
5583
5980
  processStart: identity,
@@ -5605,7 +6002,8 @@ async function startHost(args, directory, journeyId, deadline) {
5605
6002
  deadline
5606
6003
  }, () => ({
5607
6004
  exited,
5608
- spawnFailure
6005
+ spawnFailure,
6006
+ ready
5609
6007
  }));
5610
6008
  } finally {
5611
6009
  if (child.connected) child.disconnect();
@@ -5618,29 +6016,27 @@ async function publishRecord(directory, id, record) {
5618
6016
  }
5619
6017
  async function readStartup(log) {
5620
6018
  let busy = false;
5621
- let ready;
5622
6019
  for (const line of (await readFile(log, "utf8")).split("\n")) try {
5623
6020
  const parsed = JSON.parse(line);
5624
6021
  const failure = failureSchema.safeParse(parsed);
5625
6022
  if (failure.success) busy = failure.data.error.code === "COORDINATION_BUSY";
5626
- const value = readySchema.safeParse(parsed);
5627
- if (value.success) ready ??= value.data.result;
5628
6023
  } catch {}
5629
- return {
5630
- busy,
5631
- ready
5632
- };
6024
+ return { busy };
5633
6025
  }
5634
6026
  async function waitForReady(child, startup, state) {
5635
6027
  let busy = false;
5636
6028
  while (Date.now() < startup.deadline) {
5637
6029
  const failure = state().spawnFailure;
5638
6030
  if (failure) throw new Error("HOST_START_FAILED", { cause: failure });
5639
- const observed = await readStartup(startup.log);
5640
- busy = observed.busy;
5641
- if (observed.ready?.journeyId === startup.journeyId && child.pid !== void 0) {
6031
+ busy = (await readStartup(startup.log)).busy;
6032
+ const ready = state().ready;
6033
+ if (ready?.journeyId === startup.journeyId && child.pid !== void 0) {
6034
+ if (!focusedPanelUrl(ready.url, startup.journeyId)) throw new Error("INVALID_PANEL_URL");
5642
6035
  const record = {
5643
- ...observed.ready,
6036
+ ...ready,
6037
+ version: packageIdentity.version,
6038
+ currentKeepAuthority: true,
6039
+ outputVersion: 1,
5644
6040
  pid: child.pid,
5645
6041
  processStart: startup.identity,
5646
6042
  log: startup.log
@@ -5725,357 +6121,61 @@ async function readSendHost(stateDir) {
5725
6121
  }
5726
6122
  }
5727
6123
  //#endregion
5728
- //#region apps/cli/src/account/journey/remote-error.ts
5729
- const refusalSchema = z.object({
5730
- error: z.string(),
5731
- preview: sendSettingsSchema.nullable().optional(),
5732
- resolution: sendResolutionSchema.optional(),
5733
- /** `unknown_keep_work_ids`: the kept IDs Passport did not accept (ADR 0204). */
5734
- workIds: z.array(z.string()).optional()
5735
- });
5736
- var JourneyRemoteError = class extends Error {
5737
- retryAfter;
5738
- status;
5739
- code;
5740
- details;
5741
- constructor(status, code, details, options, retryAfter) {
5742
- super(code, options);
5743
- this.name = "JourneyRemoteError";
5744
- this.retryAfter = retryAfter;
5745
- this.status = status;
5746
- this.code = code;
5747
- this.details = details;
5748
- }
5749
- };
5750
- //#endregion
5751
- //#region apps/cli/src/account/journey/session-transport.ts
5752
- /** Session requests allow ten minutes for response headers and response-body inactivity. */
5753
- const sessionRequestTimeoutMs = 6e5;
5754
- /**
5755
- * Node 24 bundles undici but does not expose its Agent as a public built-in module.
5756
- * Reuse its dispatcher through the versioned compatibility symbol, without changing
5757
- * global transport policy. Constructing Request initializes undici without any I/O.
5758
- * Fail closed if a future runtime removes this bridge; never fall back to five minutes.
5759
- */
5760
- function runtimeSessionDispatcher() {
5761
- new Request("https://session-transport.invalid");
5762
- const dispatcher = Reflect.get(globalThis, Symbol.for("undici.globalDispatcher.1"));
5763
- if (typeof dispatcher !== "object" || dispatcher === null || !("dispatch" in dispatcher) || typeof dispatcher.dispatch !== "function") throw new ApplicationError("SESSION_TRANSPORT_UNAVAILABLE");
5764
- const dispatch = dispatcher.dispatch.bind(dispatcher);
5765
- return { dispatch(options, handler) {
5766
- return dispatch(options, handler);
5767
- } };
5768
- }
5769
- /** Per-request overrides reach undici's parser; AbortSignal alone does not change these. */
5770
- function sessionDispatcher(delegate = runtimeSessionDispatcher(), timeoutMs = sessionRequestTimeoutMs) {
5771
- return { dispatch(options, handler) {
5772
- return delegate.dispatch({
5773
- ...options,
5774
- headersTimeout: timeoutMs,
5775
- bodyTimeout: timeoutMs
5776
- }, handler);
5777
- } };
6124
+ //#region apps/cli/src/journey/send-extension.ts
6125
+ function selectionKey({ output, extension, identities }) {
6126
+ return hash$1([
6127
+ output.journeyId,
6128
+ output.workId,
6129
+ output.primary.sha256,
6130
+ output.finalMaskLedger.sha256,
6131
+ identities,
6132
+ extension
6133
+ ]);
5778
6134
  }
5779
- //#endregion
5780
- //#region apps/cli/src/journey/send-sessions.ts
5781
- /** Build upload identities from frozen local metadata and bytes from the final primary only. */
5782
- async function* sessionArtifacts(storage, find, journeyId, artifact, signal) {
5783
- const output = await find(journeyId, artifact.workId, artifact.revision);
5784
- if (output.bundleId !== artifact.bundleId || output.primary.sha256 !== artifact.sha256 || output.primary.archiveBytes !== artifact.byteLength) throw new ApplicationError("ARTIFACT_IDENTITY_MISMATCH");
5785
- const base = (await storage.resources({
5786
- journeyId,
5787
- kind: "base"
5788
- })).find((item) => item.kind === "base" && item.workId === artifact.workId && item.inputRevision === output.finalMaskLedger.inputRevision && item.primary.sha256 === output.finalMaskLedger.basePrimarySha256);
5789
- const plan = (await storage.resources({
5790
- journeyId,
5791
- kind: "plan"
5792
- })).find((item) => item.kind === "plan");
5793
- if (base?.kind !== "base" || plan?.kind !== "plan") throw new ApplicationError("ARTIFACT_IDENTITY_MISMATCH");
5794
- const identities = base.sessions.map((binding) => {
5795
- const session = plan.approval.inventory.sessions.find((item) => item.id === binding.applicationSessionId);
5796
- const part = session?.parts[0];
5797
- const lastAt = session?.lastInteractionAt ?? session?.createdAt;
5798
- if (!session || !part || !lastAt || !Number.isFinite(Date.parse(lastAt))) throw new ApplicationError("ARTIFACT_IDENTITY_MISMATCH");
5799
- const nativeId = part.nativeEvidence?.identity?.sessionId ?? session.sourceSessionId;
5800
- const path = part.harnessRelativePath ?? "";
5801
- if (!path && !nativeId?.trim() || isAbsolute(path) || path.includes("\\") || path.split("/").some((segment) => segment === ".." || segment === ".")) throw new ApplicationError("ARTIFACT_IDENTITY_MISMATCH");
5802
- return {
5803
- archiveSessionId: binding.archiveSessionId,
5804
- relativePath: path,
5805
- lastAt: new Date(lastAt).toISOString()
5806
- };
5807
- });
5808
- for await (const session of configuredSessionUploads(output.primary, identities, signal)) yield {
5809
- ...session,
5810
- async upload(upload, uploadSignal, onProgress) {
5811
- await find(journeyId, artifact.workId, artifact.revision);
5812
- const url = new URL(upload.uploadUrl);
5813
- if (url.protocol !== "https:" || url.username || url.password || url.hash) throw new ApplicationError("INVALID_ARGUMENTS");
5814
- const dispatcher = sessionDispatcher();
5815
- let bytes = 0;
5816
- const progressFailure = {};
5817
- const request = {
5818
- method: "PUT",
5819
- body: session.stream().pipeThrough(new TransformStream({ async transform(chunk, controller) {
5820
- bytes += chunk.byteLength;
5821
- try {
5822
- await onProgress?.(bytes);
5823
- } catch (cause) {
5824
- progressFailure.error = { cause };
5825
- throw cause;
5826
- }
5827
- controller.enqueue(chunk);
5828
- } })),
5829
- signal: uploadSignal,
5830
- dispatcher,
5831
- redirect: "error",
5832
- credentials: "omit",
5833
- referrerPolicy: "no-referrer",
5834
- headers: {
5835
- "content-type": "application/zip",
5836
- "content-length": String(session.byteLength),
5837
- ...upload.headers
5838
- },
5839
- duplex: "half"
5840
- };
5841
- const response = await fetch(url, request).catch((error) => {
5842
- if (progressFailure.error) throw progressFailure.error.cause;
5843
- throw error;
6135
+ /** Sender-local cache. Its only values are archive IDs already covered by a scope-bound proof. */
6136
+ function createSendExtensionSelection() {
6137
+ const cache = /* @__PURE__ */ new Map();
6138
+ return async function select(storage, descriptor, signal) {
6139
+ const { output, identities, extension } = descriptor;
6140
+ if (!extension) return /* @__PURE__ */ new Set();
6141
+ const key = selectionKey(descriptor);
6142
+ const prior = cache.get(key);
6143
+ if (prior) return prior;
6144
+ const bindings = /* @__PURE__ */ new Map();
6145
+ for (const binding of extension.sources) {
6146
+ const location = JSON.stringify([binding.session, binding.source]);
6147
+ if (bindings.has(location)) throw new ApplicationError("ARTIFACT_IDENTITY_MISMATCH");
6148
+ bindings.set(location, binding.part);
6149
+ }
6150
+ const dates = new Map(identities.map((item) => [item.archiveSessionId, item.lastAt]));
6151
+ const proof = await sessionExtensions({
6152
+ primary: output.primary,
6153
+ ledger: output.finalMaskLedger,
6154
+ groups: extension.groups,
6155
+ sourceFor: (session, source) => bindings.get(JSON.stringify([session, source])),
6156
+ lastAt: (session) => dates.get(session) ?? ""
6157
+ }, signal);
6158
+ if (!proof) return /* @__PURE__ */ new Set();
6159
+ if (proof.retired.size) for (const work of extension.outputs) {
6160
+ const { output: candidate } = await storage.readSendOutput({
6161
+ journeyId: output.journeyId,
6162
+ workId: work.workId,
6163
+ revision: work.revision,
6164
+ sessions: false
5844
6165
  });
5845
- await response.body?.cancel();
5846
- if (!response.ok) throw new JourneyRemoteError(response.status, "UPLOAD_FAILED", null, void 0, response.headers.get("retry-after") ?? void 0);
5847
- }
5848
- };
5849
- }
5850
- /** Own one live upload iterator; advancing it releases the preceding session ZIP. */
5851
- async function prepareSessionArtifacts(storage, find, journeyId, artifact, signal) {
5852
- const source = sessionArtifacts(storage, find, journeyId, artifact, signal);
5853
- let closed = false;
5854
- async function* iterate() {
5855
- try {
5856
- yield* source;
5857
- } finally {
5858
- closed = true;
5859
- }
5860
- }
5861
- const items = iterate();
5862
- return {
5863
- items,
5864
- async close() {
5865
- if (closed) return;
5866
- closed = true;
5867
- await items.return(void 0);
5868
- }
5869
- };
5870
- }
5871
- //#endregion
5872
- //#region apps/cli/src/journey/send-artifacts.ts
5873
- function createJourneySendArtifacts(storage) {
5874
- async function find(journeyId, workId, revision) {
5875
- const state = await storage.current(journeyId);
5876
- if (!state?.consent) throw new ApplicationError("CONFIRMATION_REQUIRED");
5877
- assertJourneyPrivacyReady(state, true);
5878
- if (state.phase === "paused") throw new ApplicationError("OPERATION_CANCELED");
5879
- const work = state.works.find((item) => item.id === workId);
5880
- if (!work?.artifact || work.revision !== revision || work.artifact.revision !== revision) throw new ApplicationError("REVISION_CONFLICT");
5881
- const output = (await storage.resources({
5882
- journeyId,
5883
- kind: "output"
5884
- })).find((item) => item.kind === "output" && item.workId === workId && item.revision === revision);
5885
- if (output?.kind !== "output" || output.primary.sha256 !== work.artifact.sha256) throw new ApplicationError("ARTIFACT_IDENTITY_MISMATCH");
5886
- return output;
5887
- }
5888
- return {
5889
- prepareSessions: (journeyId, artifact, signal) => prepareSessionArtifacts(storage, find, journeyId, artifact, signal),
5890
- sessions: (journeyId, artifact, signal) => sessionArtifacts(storage, find, journeyId, artifact, signal),
5891
- async describe(journey, work, signal) {
5892
- const output = await find(journey.journeyId, work.id, work.revision);
5893
- const info = await readConfiguredOutputInfo(output.primary, signal);
5894
- if (info.work.id !== work.id || info.work.access !== work.access) throw new ApplicationError("ARTIFACT_IDENTITY_MISMATCH");
5895
- let agentSources = [];
5896
- try {
5897
- const base = (await storage.resources({
5898
- journeyId: journey.journeyId,
5899
- kind: "base"
5900
- })).find((item) => item.kind === "base" && item.workId === work.id && item.inputRevision === output.finalMaskLedger.inputRevision && item.primary.sha256 === output.finalMaskLedger.basePrimarySha256);
5901
- if (base?.kind === "base") {
5902
- const included = new Set(base.sessions.map((item) => item.applicationSessionId));
5903
- const resources = await storage.resources({
5904
- journeyId: journey.journeyId,
5905
- kind: "static-session"
5906
- });
5907
- const unique = /* @__PURE__ */ new Map();
5908
- for (const resource of resources) {
5909
- if (resource.kind !== "static-session" || resource.workId !== work.id || resource.inputRevision !== base.inputRevision || !included.has(resource.sessionId)) continue;
5910
- for (const candidate of resource.agentSources ?? []) {
5911
- const parsed = agentSourceSchema.safeParse(candidate);
5912
- if (parsed.success && unique.size < 500) unique.set(JSON.stringify(parsed.data), parsed.data);
5913
- }
5914
- }
5915
- agentSources = [...unique.values()];
5916
- }
5917
- } catch {
5918
- console.error("AGENT_SOURCES_METADATA_UNAVAILABLE");
5919
- }
5920
- return sendArtifactSchema.parse({
5921
- ...agentSources.length === 0 ? {} : { agentSources },
5922
- workId: work.id,
5923
- revision: work.revision,
5924
- bundleId: info.manifest.bundleId,
5925
- createdAt: info.createdAt,
5926
- title: info.work.title,
5927
- sessionCount: info.sessionCount,
5928
- grouping: info.manifest.work.grouping,
5929
- sources: info.sources,
5930
- byteLength: output.primary.archiveBytes,
5931
- sha256: output.primary.sha256
5932
- });
5933
- },
5934
- async upload(journeyId, artifact, upload, signal) {
5935
- const output = await find(journeyId, artifact.workId, artifact.revision);
5936
- if (output.bundleId !== artifact.bundleId || output.primary.sha256 !== artifact.sha256 || output.primary.archiveBytes !== artifact.byteLength || artifact.byteLength > 5368709120) throw new ApplicationError("ARTIFACT_IDENTITY_MISMATCH");
5937
- await putPrimary(output.primary, artifact.byteLength, upload, signal);
5938
- }
5939
- };
5940
- }
5941
- /**
5942
- * Stream a verified primary to a signed HTTPS URL; shared by native Send and Manual upload. A
5943
- * checksum upload also sends the SHA-256 header the URL signs, so storage refuses other bytes.
5944
- */
5945
- async function putPrimary(primary, byteLength, upload, signal) {
5946
- const url = new URL(upload.uploadUrl);
5947
- if (url.protocol !== "https:" || url.username || url.password || url.hash) throw new ApplicationError("INVALID_ARGUMENTS");
5948
- const source = await openConfiguredUpload(primary, signal);
5949
- const cleanup = createCleanup();
5950
- try {
5951
- const request = {
5952
- method: "PUT",
5953
- body: source.stream,
5954
- signal,
5955
- redirect: "error",
5956
- headers: {
5957
- "Content-Type": "application/zip",
5958
- "Content-Length": String(byteLength),
5959
- ...upload.headers
5960
- },
5961
- duplex: "half"
5962
- };
5963
- const result = await fetch(url, request);
5964
- await result.body?.cancel();
5965
- if (!result.ok) throw new ApplicationError("UPLOAD_FAILED");
5966
- } catch (error) {
5967
- cleanup.failed(error);
5968
- throw error;
5969
- } finally {
5970
- await cleanup.run(() => source.close());
5971
- }
5972
- }
5973
- //#endregion
5974
- //#region apps/cli/src/account/journey/session-facts-cache.ts
5975
- const keyFor = (journeyId, artifact) => JSON.stringify([
5976
- journeyId,
5977
- artifact.workId,
5978
- artifact.revision,
5979
- artifact.sha256
5980
- ]);
5981
- /** Sender-owned facts only. In-flight computations never enter the bounded cache. */
5982
- function createSessionFactsCache(limit = 256) {
5983
- const facts = /* @__PURE__ */ new Map();
5984
- let owner = "";
5985
- let generation = 0;
5986
- let allowed = /* @__PURE__ */ new Set();
5987
- return {
5988
- retain(journeyId, send) {
5989
- const nextOwner = JSON.stringify([journeyId, send?.id]);
5990
- const ended = !send || [
5991
- "completed",
5992
- "blocked",
5993
- "prepared"
5994
- ].includes(send.status);
5995
- if (owner !== nextOwner || ended) {
5996
- facts.clear();
5997
- generation += 1;
5998
- }
5999
- owner = nextOwner;
6000
- allowed = new Set(ended || !send ? [] : send.items.filter((item) => !item.sessionAssembly && item.status !== "completed").map((item) => keyFor(journeyId, item.artifact)));
6001
- for (const key of facts.keys()) if (!allowed.has(key)) facts.delete(key);
6002
- },
6003
- async get(journeyId, artifact, signal, compute) {
6004
- signal.throwIfAborted();
6005
- const key = keyFor(journeyId, artifact);
6006
- const cached = facts.get(key);
6007
- if (cached) return cached;
6008
- const started = generation;
6009
- const items = await compute();
6010
- signal.throwIfAborted();
6011
- if (started === generation && allowed.has(key)) {
6012
- facts.set(key, items);
6013
- while (facts.size > limit) {
6014
- const oldest = facts.keys().next().value;
6015
- if (oldest === void 0) break;
6016
- facts.delete(oldest);
6017
- }
6018
- }
6019
- return items;
6020
- }
6021
- };
6022
- }
6023
- //#endregion
6024
- //#region apps/cli/src/account/journey/session-preparation.ts
6025
- /** Shared facts retain no bytes; only upload steps acquire phase-local live iterators. */
6026
- function createSessionPreparation(dependencies, facts) {
6027
- const prepare = dependencies.artifacts.prepareSessions;
6028
- const prepared = [];
6029
- const sessions = async function* (journeyId, artifact, signal) {
6030
- if (!prepare) {
6031
- const original = dependencies.artifacts.sessions;
6032
- if (original) yield* original(journeyId, artifact, signal);
6033
- return;
6034
- }
6035
- const result = await prepare(journeyId, artifact, signal);
6036
- prepared.push(result);
6037
- for await (const item of result.items) {
6038
- signal.throwIfAborted();
6039
- yield item;
6040
- }
6041
- };
6042
- const sessionFacts = async function* (journeyId, artifact, signal) {
6043
- const items = await facts.get(journeyId, artifact, signal, async () => {
6044
- const original = dependencies.artifacts.sessionFacts ?? dependencies.artifacts.sessions;
6045
- const items = [];
6046
- if (original) for await (const item of original(journeyId, artifact, signal)) {
6047
- const { workerIdentity, nativeSessionId, relativePath, harness, lastAt, metadata, sha256, byteLength, crc32 } = item;
6048
- items.push({
6049
- ...workerIdentity ? { workerIdentity } : {},
6050
- nativeSessionId,
6051
- relativePath,
6052
- harness,
6053
- lastAt,
6054
- metadata,
6055
- sha256,
6056
- byteLength,
6057
- crc32
6058
- });
6059
- }
6060
- return items;
6061
- });
6062
- for (const item of items) {
6063
- signal.throwIfAborted();
6064
- yield item;
6065
- }
6066
- };
6067
- return {
6068
- dependencies: {
6069
- ...dependencies,
6070
- artifacts: {
6071
- ...dependencies.artifacts,
6072
- sessions,
6073
- sessionFacts
6074
- }
6075
- },
6076
- async close() {
6077
- await createCleanup().run(...prepared.map((result) => () => result.close()));
6166
+ if (candidate.primary.sha256 !== work.sha256 || candidate.bundleId !== work.bundleId) throw new ApplicationError("ARTIFACT_IDENTITY_MISMATCH");
6167
+ const omitted = candidate.workId === output.workId ? proof.omitted : /* @__PURE__ */ new Set();
6168
+ if (await hasRetiredSessionMarker(candidate.primary, omitted, proof.retired, signal)) return /* @__PURE__ */ new Set();
6078
6169
  }
6170
+ if (selectionKey(await storage.readSendOutput({
6171
+ journeyId: output.journeyId,
6172
+ workId: output.workId,
6173
+ revision: output.revision,
6174
+ sessions: true
6175
+ })) !== key) throw new ApplicationError("REVISION_CONFLICT");
6176
+ if (cache.size >= 32) cache.clear();
6177
+ cache.set(key, proof.omitted);
6178
+ return proof.omitted;
6079
6179
  };
6080
6180
  }
6081
6181
  //#endregion
@@ -6144,15 +6244,86 @@ function createSessionKeys() {
6144
6244
  };
6145
6245
  }
6146
6246
  //#endregion
6147
- //#region apps/cli/src/account/journey/session-access.ts
6247
+ //#region apps/cli/src/account/journey/session-content-claims.ts
6148
6248
  /** Safe facts for a local preflight conflict; no paths or session content escape. */
6149
6249
  var SessionContentConflict = class extends ApplicationError {
6150
6250
  contentConflict;
6151
- constructor(contentConflict) {
6251
+ affectedWorkIds;
6252
+ constructor(contentConflict, affectedWorkIds) {
6152
6253
  super("SESSION_CONTENT_CONFLICT");
6153
6254
  this.contentConflict = contentConflict;
6255
+ this.affectedWorkIds = affectedWorkIds;
6154
6256
  }
6155
6257
  };
6258
+ function sessionContentClaims(items, inventory) {
6259
+ const requestedDigests = /* @__PURE__ */ new Map();
6260
+ const divergent = /* @__PURE__ */ new Set();
6261
+ const contentOwners = /* @__PURE__ */ new Map();
6262
+ const savedUploads = /* @__PURE__ */ new Map();
6263
+ const owners = /* @__PURE__ */ new Map();
6264
+ const holdings = /* @__PURE__ */ new Map();
6265
+ function hold(key, workId) {
6266
+ const byKey = owners.get(key) ?? /* @__PURE__ */ new Set();
6267
+ const byWork = holdings.get(workId) ?? /* @__PURE__ */ new Set();
6268
+ byKey.add(workId);
6269
+ byWork.add(key);
6270
+ owners.set(key, byKey);
6271
+ holdings.set(workId, byWork);
6272
+ }
6273
+ const byId = new Map(items.map((item) => [item.workId, item]));
6274
+ for (const work of inventory.works) if (byId.has(work.logicalWorkId)) for (const stored of work.sessions) hold(stored.key, work.logicalWorkId);
6275
+ for (const item of items) {
6276
+ if (item.sessionUpload) {
6277
+ hold(item.sessionUpload.key, item.workId);
6278
+ const claims = savedUploads.get(item.sessionUpload.key) ?? /* @__PURE__ */ new Map();
6279
+ claims.set(item.workId, item.sessionUpload.sha256);
6280
+ savedUploads.set(item.sessionUpload.key, claims);
6281
+ }
6282
+ for (const key of item.sessionMembership?.keys ?? []) hold(key, item.workId);
6283
+ }
6284
+ return {
6285
+ observe(key, sha256, item) {
6286
+ hold(key, item.workId);
6287
+ const requestedBy = contentOwners.get(key) ?? /* @__PURE__ */ new Set();
6288
+ requestedBy.add(item.workId);
6289
+ contentOwners.set(key, requestedBy);
6290
+ for (const [owner, sha] of savedUploads.get(key) ?? []) if (owner !== item.workId && byId.get(owner)?.access === item.access && sha !== sha256) divergent.add(key);
6291
+ const otherDigest = requestedDigests.get(key);
6292
+ if (otherDigest !== void 0 && otherDigest !== sha256) divergent.add(key);
6293
+ requestedDigests.set(key, sha256);
6294
+ },
6295
+ requireIndependent() {
6296
+ for (const [key, claims] of savedUploads) {
6297
+ for (const access of ["private", "public"]) if (new Set([...claims].filter(([id]) => byId.get(id)?.access === access).map(([, sha]) => sha)).size > 1) divergent.add(key);
6298
+ if (!divergent.has(key)) continue;
6299
+ const requestedBy = contentOwners.get(key) ?? /* @__PURE__ */ new Set();
6300
+ for (const owner of claims.keys()) requestedBy.add(owner);
6301
+ contentOwners.set(key, requestedBy);
6302
+ }
6303
+ const [first] = divergent;
6304
+ if (first) {
6305
+ const affected = /* @__PURE__ */ new Set();
6306
+ const pending = [...divergent];
6307
+ const visited = new Set(pending);
6308
+ for (const key of pending) for (const workId of owners.get(key) ?? []) {
6309
+ if (affected.has(workId)) continue;
6310
+ affected.add(workId);
6311
+ for (const owned of holdings.get(workId) ?? []) if (!visited.has(owned)) {
6312
+ visited.add(owned);
6313
+ pending.push(owned);
6314
+ }
6315
+ }
6316
+ if (items.some((item) => affected.has(item.workId) && !["blocked", "completed"].includes(item.status) && !item.sessionAssembly)) throw new SessionContentConflict({
6317
+ workIds: items.filter((item) => contentOwners.get(first)?.has(item.workId)).map((item) => item.workId),
6318
+ sessionKey: first
6319
+ }, affected);
6320
+ }
6321
+ }
6322
+ };
6323
+ }
6324
+ //#endregion
6325
+ //#region apps/cli/src/account/journey/session-access.ts
6326
+ const passportSessionPartByteLimit = 5 * 1024 ** 3 - 128 * 1024 ** 2;
6156
6327
  /** Collect safe conflicts across the entire pending Send before any session mutation. */
6157
6328
  async function sessionAccessBlocked(dependencies, journeyId, items, inventory, signal) {
6158
6329
  const sessions = dependencies.artifacts.sessionFacts ?? dependencies.artifacts.sessions;
@@ -6177,7 +6348,7 @@ async function sessionAccessBlocked(dependencies, journeyId, items, inventory, s
6177
6348
  conflicts.set(JSON.stringify(conflict), conflict);
6178
6349
  }
6179
6350
  const requestedAccess = /* @__PURE__ */ new Map();
6180
- const requestedDigests = /* @__PURE__ */ new Map();
6351
+ const content = sessionContentClaims(items, inventory);
6181
6352
  const contentAccess = /* @__PURE__ */ new Map();
6182
6353
  function claim(claims, identity, key, item, rule) {
6183
6354
  const previous = claims.get(identity) ?? [];
@@ -6204,38 +6375,42 @@ async function sessionAccessBlocked(dependencies, journeyId, items, inventory, s
6204
6375
  for (const source of sources) if (source.logicalWorkId !== item.workId && source.access !== item.access) record(item, key, "held-elsewhere", source);
6205
6376
  const sha256 = item.sessionMembership ? sources[0]?.sessions.find((session) => session.key === key)?.sha256 : item.sessionUpload?.sha256;
6206
6377
  if (sha256) claimContent(key, sha256, item);
6207
- if (destination && destination.access !== item.access && !destination.sessions.some((entry) => entry.key === key && (item.sessionMembership || entry.sha256 === sha256))) record(item, key, "destination-access", destination);
6378
+ if (destination && destination.access !== item.access) record(item, key, "destination-access", destination);
6208
6379
  }
6209
6380
  }
6210
6381
  for (const item of items) {
6211
- if (item.sessionAssembly || item.sessionMembership || ["completed", "blocked"].includes(item.status)) continue;
6382
+ if (item.sessionAssembly || item.sessionMembership || item.status === "completed" || item.status === "blocked" && item.error !== "SESSION_CONTENT_CONFLICT") continue;
6212
6383
  const destination = inventory.works.find((work) => work.logicalWorkId === item.workId);
6213
6384
  if (destination?.access === "removed") throw new ApplicationError("WORK_REMOVED");
6214
6385
  if (destination) observe(item, destination.access);
6386
+ const selected = createSessionKeys();
6387
+ const measured = /* @__PURE__ */ new Set();
6388
+ let partBytes = 0;
6389
+ let partCount = 0;
6215
6390
  for await (const session of sessions(journeyId, item.artifact, signal)) {
6216
- const key = await sessionKeyFor(session);
6217
- const { workerIdentity: _, ...original } = session;
6218
- const legacyKey = session.workerIdentity ? await sessionKeyFor(original) : key;
6391
+ const identity = await selected.add(session);
6392
+ const key = identity.key;
6393
+ const legacyKey = identity.legacyKey ?? key;
6394
+ if (!measured.has(key)) {
6395
+ measured.add(key);
6396
+ partCount += session.metadata.parts.length;
6397
+ for (const part of session.metadata.parts) partBytes += part.byteLength;
6398
+ }
6399
+ content.observe(key, session.sha256, item);
6400
+ if (item.status === "blocked") continue;
6219
6401
  claimContent(key, session.sha256, item);
6220
6402
  if (legacyKey !== key) claimContent(legacyKey, session.sha256, item);
6221
6403
  claim(requestedAccess, key, key, item, "requested-access");
6222
- const otherDigest = requestedDigests.get(key);
6223
- if (otherDigest !== void 0 && otherDigest.sha256 !== session.sha256) throw new SessionContentConflict({
6224
- workIds: [.../* @__PURE__ */ new Set([...otherDigest.workIds, item.workId])],
6225
- sessionKey: key
6226
- });
6227
- requestedDigests.set(key, {
6228
- sha256: session.sha256,
6229
- workIds: [.../* @__PURE__ */ new Set([...otherDigest?.workIds ?? [], item.workId])]
6230
- });
6231
6404
  for (const work of inventory.works) for (const entry of work.sessions) {
6232
6405
  if (entry.key !== key && !(entry.key === legacyKey && entry.sha256 === session.sha256)) continue;
6233
6406
  observe(item, work.access);
6234
6407
  if (work.logicalWorkId !== item.workId && work.access !== item.access) record(item, entry.key, "held-elsewhere", work);
6235
6408
  }
6236
- if (destination && destination.access !== item.access && !destination.sessions.some((entry) => entry.key === key && entry.sha256 === session.sha256)) record(item, key, "destination-access", destination);
6409
+ if (destination && destination.access !== item.access) record(item, key, "destination-access", destination);
6237
6410
  }
6411
+ if (selected.membership(destination?.sessions ?? []).length > 1e3 || partCount > 1e4 || partBytes > passportSessionPartByteLimit) throw new ApplicationError("WORK_SESSION_CAPACITY_EXCEEDED");
6238
6412
  }
6413
+ content.requireIndependent();
6239
6414
  return [...conflicts.values()].map((conflict) => (accountAccesses.get(conflict.workId)?.size ?? 0) > 1 ? {
6240
6415
  ...conflict,
6241
6416
  accountAccess: "mixed"
@@ -6261,23 +6436,261 @@ async function sessionIndependentItems(dependencies, journeyId, items, inventory
6261
6436
  return independent;
6262
6437
  }
6263
6438
  //#endregion
6264
- //#region apps/cli/src/account/journey/receipt.ts
6265
- function acceptReceipt(item, receipt) {
6266
- if (receipt.logicalWorkId !== item.workId || receipt.bundleId !== item.artifact.bundleId || receipt.outputRevision !== item.artifact.revision || receipt.sha256 !== item.artifact.sha256 || receipt.byteLength !== item.artifact.byteLength || receipt.access !== item.access || item.receipt !== null && (receipt.receiptId !== item.receipt.receiptId || receipt.scanId !== item.receipt.scanId || receipt.bindingId !== item.receipt.bindingId) || item.target?.kind === "existing" && receipt.bindingId !== item.target.bindingId) throw new ApplicationError("ARTIFACT_IDENTITY_MISMATCH");
6267
- const { upload: _upload, ...saved } = receipt;
6268
- const success = receipt.state === "succeeded" && receipt.assessmentState === "succeeded" && receipt.passportIncluded && receipt.effectiveAccess === item.access;
6269
- const failure = [
6270
- "failed",
6271
- "partial",
6272
- "superseded"
6273
- ].includes(receipt.state) || receipt.state === "succeeded" && !success;
6439
+ //#region apps/cli/src/account/journey/remote-error.ts
6440
+ const refusalSchema = z.object({
6441
+ error: z.string(),
6442
+ preview: sendSettingsSchema.nullable().optional(),
6443
+ resolution: sendResolutionSchema.optional(),
6444
+ /** `unknown_keep_work_ids`: the kept IDs Passport did not accept (ADR 0204). */
6445
+ workIds: z.array(z.string()).optional()
6446
+ });
6447
+ var JourneyRemoteError = class extends Error {
6448
+ retryAfter;
6449
+ status;
6450
+ code;
6451
+ details;
6452
+ constructor(status, code, details, options, retryAfter) {
6453
+ super(code, options);
6454
+ this.name = "JourneyRemoteError";
6455
+ this.retryAfter = retryAfter;
6456
+ this.status = status;
6457
+ this.code = code;
6458
+ this.details = details;
6459
+ }
6460
+ };
6461
+ //#endregion
6462
+ //#region apps/cli/src/account/journey/session-transport.ts
6463
+ /** Session requests allow ten minutes for response headers and response-body inactivity. */
6464
+ const sessionRequestTimeoutMs = 6e5;
6465
+ /**
6466
+ * Node 24 bundles undici but does not expose its Agent as a public built-in module.
6467
+ * Reuse its dispatcher through the versioned compatibility symbol, without changing
6468
+ * global transport policy. Constructing Request initializes undici without any I/O.
6469
+ * Fail closed if a future runtime removes this bridge; never fall back to five minutes.
6470
+ */
6471
+ function runtimeSessionDispatcher() {
6472
+ new Request("https://session-transport.invalid");
6473
+ const dispatcher = Reflect.get(globalThis, Symbol.for("undici.globalDispatcher.1"));
6474
+ if (typeof dispatcher !== "object" || dispatcher === null || !("dispatch" in dispatcher) || typeof dispatcher.dispatch !== "function") throw new ApplicationError("SESSION_TRANSPORT_UNAVAILABLE");
6475
+ const dispatch = dispatcher.dispatch.bind(dispatcher);
6476
+ return { dispatch(options, handler) {
6477
+ return dispatch(options, handler);
6478
+ } };
6479
+ }
6480
+ /** Per-request overrides reach undici's parser; AbortSignal alone does not change these. */
6481
+ function sessionDispatcher(delegate = runtimeSessionDispatcher(), timeoutMs = sessionRequestTimeoutMs) {
6482
+ return { dispatch(options, handler) {
6483
+ return delegate.dispatch({
6484
+ ...options,
6485
+ headersTimeout: timeoutMs,
6486
+ bodyTimeout: timeoutMs
6487
+ }, handler);
6488
+ } };
6489
+ }
6490
+ //#endregion
6491
+ //#region apps/cli/src/journey/send-sessions.ts
6492
+ /** Build upload identities from frozen local metadata and bytes from the final primary only. */
6493
+ async function sessionInputs(storage, journeyId, artifact, signal, select) {
6494
+ const query = {
6495
+ journeyId,
6496
+ workId: artifact.workId,
6497
+ revision: artifact.revision
6498
+ };
6499
+ const descriptor = await storage.readSendOutput({
6500
+ ...query,
6501
+ sessions: true
6502
+ });
6503
+ const { output, identities } = descriptor;
6504
+ if (output.bundleId !== artifact.bundleId || output.primary.sha256 !== artifact.sha256 || output.primary.archiveBytes !== artifact.byteLength) throw new ApplicationError("ARTIFACT_IDENTITY_MISMATCH");
6274
6505
  return {
6275
- ...item,
6276
- receipt: saved,
6277
- status: success ? "completed" : failure ? "blocked" : receipt.state === "awaiting_upload" ? "uploading" : "assessing",
6278
- error: failure ? receipt.state === "succeeded" ? "PASSPORT_NOT_CURRENT" : "ASSESSMENT_INCOMPLETE" : null
6506
+ output,
6507
+ identities,
6508
+ omitted: await select(storage, descriptor, signal)
6509
+ };
6510
+ }
6511
+ async function* sessionFacts(storage, journeyId, artifact, signal, select = createSendExtensionSelection()) {
6512
+ const { output, identities, omitted } = await sessionInputs(storage, journeyId, artifact, signal, select);
6513
+ yield* configuredSessionUploads(output.primary, identities, signal, "facts", omitted, (bytes) => {
6514
+ if (bytes > passportSessionPartByteLimit) throw new ApplicationError("WORK_SESSION_CAPACITY_EXCEEDED");
6515
+ });
6516
+ }
6517
+ async function* sessionArtifacts(storage, journeyId, artifact, signal, select = createSendExtensionSelection()) {
6518
+ const { output, identities, omitted } = await sessionInputs(storage, journeyId, artifact, signal, select);
6519
+ for await (const session of configuredSessionUploads(output.primary, identities, signal, "upload", omitted)) yield {
6520
+ ...session,
6521
+ async upload(upload, uploadSignal, onProgress) {
6522
+ await storage.readSendOutput({
6523
+ journeyId,
6524
+ workId: artifact.workId,
6525
+ revision: artifact.revision,
6526
+ sessions: false
6527
+ });
6528
+ const url = new URL(upload.uploadUrl);
6529
+ if (url.protocol !== "https:" || url.username || url.password || url.hash) throw new ApplicationError("INVALID_ARGUMENTS");
6530
+ const dispatcher = sessionDispatcher();
6531
+ let bytes = 0;
6532
+ const progressFailure = {};
6533
+ const request = {
6534
+ method: "PUT",
6535
+ body: session.stream().pipeThrough(new TransformStream({ async transform(chunk, controller) {
6536
+ bytes += chunk.byteLength;
6537
+ try {
6538
+ await onProgress?.(bytes);
6539
+ } catch (cause) {
6540
+ progressFailure.error = { cause };
6541
+ throw cause;
6542
+ }
6543
+ controller.enqueue(chunk);
6544
+ } })),
6545
+ signal: uploadSignal,
6546
+ dispatcher,
6547
+ redirect: "error",
6548
+ credentials: "omit",
6549
+ referrerPolicy: "no-referrer",
6550
+ headers: {
6551
+ "content-type": "application/zip",
6552
+ "content-length": String(session.byteLength),
6553
+ ...upload.headers
6554
+ },
6555
+ duplex: "half"
6556
+ };
6557
+ const response = await fetch(url, request).catch((error) => {
6558
+ if (progressFailure.error) throw progressFailure.error.cause;
6559
+ throw error;
6560
+ });
6561
+ await response.body?.cancel();
6562
+ if (!response.ok) throw new JourneyRemoteError(response.status, "UPLOAD_FAILED", null, void 0, response.headers.get("retry-after") ?? void 0);
6563
+ }
6564
+ };
6565
+ }
6566
+ /** Own one live upload iterator; advancing it releases the preceding session ZIP. */
6567
+ async function prepareSessionArtifacts(storage, journeyId, artifact, signal, select = createSendExtensionSelection()) {
6568
+ const source = sessionArtifacts(storage, journeyId, artifact, signal, select);
6569
+ let closed = false;
6570
+ async function* iterate() {
6571
+ try {
6572
+ yield* source;
6573
+ } finally {
6574
+ closed = true;
6575
+ }
6576
+ }
6577
+ const items = iterate();
6578
+ return {
6579
+ items,
6580
+ async close() {
6581
+ if (closed) return;
6582
+ closed = true;
6583
+ await items.return(void 0);
6584
+ }
6585
+ };
6586
+ }
6587
+ //#endregion
6588
+ //#region apps/cli/src/journey/send-artifacts.ts
6589
+ function createJourneySendArtifacts(storage) {
6590
+ const selection = createSendExtensionSelection();
6591
+ return {
6592
+ sessionFacts: (journeyId, artifact, signal) => sessionFacts(storage, journeyId, artifact, signal, selection),
6593
+ prepareSessions: (journeyId, artifact, signal) => prepareSessionArtifacts(storage, journeyId, artifact, signal, selection),
6594
+ sessions: (journeyId, artifact, signal) => sessionArtifacts(storage, journeyId, artifact, signal, selection),
6595
+ async describe(journey, work, signal) {
6596
+ const { output } = await storage.readSendOutput({
6597
+ journeyId: journey.journeyId,
6598
+ workId: work.id,
6599
+ revision: work.revision,
6600
+ sessions: false
6601
+ });
6602
+ const info = await readConfiguredOutputInfo(output.primary, signal);
6603
+ if (info.work.id !== work.id || info.work.access !== work.access) throw new ApplicationError("ARTIFACT_IDENTITY_MISMATCH");
6604
+ let agentSources = [];
6605
+ try {
6606
+ const base = (await storage.resources({
6607
+ journeyId: journey.journeyId,
6608
+ kind: "base",
6609
+ id: `${work.id}:${output.finalMaskLedger.inputRevision}`
6610
+ })).find((item) => item.kind === "base" && item.workId === work.id && item.inputRevision === output.finalMaskLedger.inputRevision && item.primary.sha256 === output.finalMaskLedger.basePrimarySha256);
6611
+ if (base?.kind === "base") {
6612
+ const ids = base.sessions.map((item) => staticSessionId({
6613
+ workId: work.id,
6614
+ inputRevision: base.inputRevision,
6615
+ sessionId: item.applicationSessionId
6616
+ }));
6617
+ const included = new Set(base.sessions.map((item) => item.applicationSessionId));
6618
+ const resources = ids.length ? await storage.resources({
6619
+ journeyId: journey.journeyId,
6620
+ kind: "static-session",
6621
+ ids
6622
+ }) : [];
6623
+ const unique = /* @__PURE__ */ new Map();
6624
+ for (const resource of resources) {
6625
+ if (resource.kind !== "static-session" || resource.workId !== work.id || resource.inputRevision !== base.inputRevision || !included.has(resource.sessionId)) continue;
6626
+ for (const candidate of resource.agentSources ?? []) {
6627
+ const parsed = agentSourceSchema.safeParse(candidate);
6628
+ if (parsed.success && unique.size < 500) unique.set(JSON.stringify(parsed.data), parsed.data);
6629
+ }
6630
+ }
6631
+ agentSources = [...unique.values()];
6632
+ }
6633
+ } catch {
6634
+ console.error("AGENT_SOURCES_METADATA_UNAVAILABLE");
6635
+ }
6636
+ return sendArtifactSchema.parse({
6637
+ ...agentSources.length === 0 ? {} : { agentSources },
6638
+ workId: work.id,
6639
+ revision: work.revision,
6640
+ bundleId: info.manifest.bundleId,
6641
+ createdAt: info.createdAt,
6642
+ title: info.work.title,
6643
+ sessionCount: info.sessionCount,
6644
+ grouping: info.manifest.work.grouping,
6645
+ sources: info.sources,
6646
+ byteLength: output.primary.archiveBytes,
6647
+ sha256: output.primary.sha256
6648
+ });
6649
+ },
6650
+ async upload(journeyId, artifact, upload, signal) {
6651
+ const { output } = await storage.readSendOutput({
6652
+ journeyId,
6653
+ workId: artifact.workId,
6654
+ revision: artifact.revision,
6655
+ sessions: false
6656
+ });
6657
+ if (output.bundleId !== artifact.bundleId || output.primary.sha256 !== artifact.sha256 || output.primary.archiveBytes !== artifact.byteLength || artifact.byteLength > 5368709120) throw new ApplicationError("ARTIFACT_IDENTITY_MISMATCH");
6658
+ await putPrimary(output.primary, artifact.byteLength, upload, signal);
6659
+ }
6279
6660
  };
6280
6661
  }
6662
+ /**
6663
+ * Stream a verified primary to a signed HTTPS URL; shared by native Send and Manual upload. A
6664
+ * checksum upload also sends the SHA-256 header the URL signs, so storage refuses other bytes.
6665
+ */
6666
+ async function putPrimary(primary, byteLength, upload, signal) {
6667
+ const url = new URL(upload.uploadUrl);
6668
+ if (url.protocol !== "https:" || url.username || url.password || url.hash) throw new ApplicationError("INVALID_ARGUMENTS");
6669
+ const source = await openConfiguredUpload(primary, signal);
6670
+ const cleanup = createCleanup();
6671
+ try {
6672
+ const request = {
6673
+ method: "PUT",
6674
+ body: source.stream,
6675
+ signal,
6676
+ redirect: "error",
6677
+ headers: {
6678
+ "Content-Type": "application/zip",
6679
+ "Content-Length": String(byteLength),
6680
+ ...upload.headers
6681
+ },
6682
+ duplex: "half"
6683
+ };
6684
+ const result = await fetch(url, request);
6685
+ await result.body?.cancel();
6686
+ if (!result.ok) throw new ApplicationError("UPLOAD_FAILED");
6687
+ } catch (error) {
6688
+ cleanup.failed(error);
6689
+ throw error;
6690
+ } finally {
6691
+ await cleanup.run(() => source.close());
6692
+ }
6693
+ }
6281
6694
  //#endregion
6282
6695
  //#region apps/cli/src/account/journey/protocol.ts
6283
6696
  /** Owner responses contain only bounded metadata, never archive contents. */
@@ -6385,6 +6798,7 @@ async function checkPublicAgentSources(candidates, requestFetch = fetch, signal)
6385
6798
  //#region apps/cli/src/account/journey/session-schema.ts
6386
6799
  const digest = z.string().regex(/^[0-9a-f]{64}$/u);
6387
6800
  const stored = z.strictObject({
6801
+ metadataSha256: digest.nullable(),
6388
6802
  key: digest,
6389
6803
  sessionKey: z.string().regex(/^sess-pilot\/[^/]+\/[^/]+\/[^/]+$/u).nullable(),
6390
6804
  sha256: digest,
@@ -6483,20 +6897,24 @@ function createSessionClient(request) {
6483
6897
  let next = null;
6484
6898
  const seen = /* @__PURE__ */ new Set();
6485
6899
  const sessions = /* @__PURE__ */ new Map();
6486
- let value;
6900
+ let access;
6487
6901
  do {
6488
6902
  const query = new URLSearchParams({ limit: "500" });
6489
6903
  if (next !== null) query.set("cursor", next);
6490
- const page = await request(`/me/works/${encodeURIComponent(id)}/sessions?${query}`, work.extend({ nextCursor: cursor }).refine((item) => item.logicalWorkId === id), signal, void 0, "GET");
6904
+ const page = await request(`/me/works/${encodeURIComponent(id)}/sessions?${query}`, work.extend({ nextCursor: cursor }).refine((item) => item.logicalWorkId === id), signal, void 0, "GET").catch((error) => {
6905
+ if (next === null && error instanceof JourneyRemoteError && error.status === 404 && error.code === "not_found") return null;
6906
+ throw error;
6907
+ });
6908
+ if (!page) return null;
6491
6909
  for (const session of page.sessions) sessions.set(session.key, session);
6492
- value = {
6493
- logicalWorkId: page.logicalWorkId,
6494
- access: page.access,
6495
- sessions: [...sessions.values()]
6496
- };
6910
+ access = page.access;
6497
6911
  next = continuation(seen, page.nextCursor);
6498
6912
  } while (next !== null);
6499
- return value;
6913
+ return {
6914
+ logicalWorkId: id,
6915
+ access,
6916
+ sessions: [...sessions.values()]
6917
+ };
6500
6918
  },
6501
6919
  reserve: (input, signal) => request("/me/sessions/reserve", reserve, signal, input),
6502
6920
  complete: (input, signal) => mutate(signal, () => request("/me/sessions/complete", z.strictObject({ state: z.literal("stored") }), signal, input, "POST", void 0, sessionRequestTimeoutMs, false, 200, "session")),
@@ -6617,6 +7035,21 @@ function createJourneyOwnerClient(options) {
6617
7035
  const { headers } = await requestWithHeaders("/me/profile", publicProfileSchema, signal);
6618
7036
  return { parallelCompletes: (headers.get("passport-capabilities") ?? "").split(",").some((token) => token.trim() === "parallel-completes") };
6619
7037
  },
7038
+ async ownedPassport(signal) {
7039
+ const webOrigin = passportWebOrigin(options.apiUrl, options.webOrigin);
7040
+ if (!webOrigin) throw new ApplicationError("ACCOUNT_PROFILE_UNAVAILABLE");
7041
+ try {
7042
+ const result = await request("/me/passport", z.strictObject({ publicId: sendReceiptSchema.shape.publicId }), signal, void 0, "GET");
7043
+ return new URL("/" + result.publicId, webOrigin).href;
7044
+ } catch (error) {
7045
+ if (error instanceof JourneyRemoteError && error.status === 404 && error.code === "passport not found") {
7046
+ await options.authorize();
7047
+ signal.throwIfAborted();
7048
+ return null;
7049
+ }
7050
+ throw error;
7051
+ }
7052
+ },
6620
7053
  readProfile(signal) {
6621
7054
  return request("/me/profile", ownerProfileSchema, signal, void 0, "GET");
6622
7055
  },
@@ -6817,40 +7250,259 @@ async function resolveItem(item, bindingId, connection, signal, snapshot = false
6817
7250
  status: "target",
6818
7251
  error: null
6819
7252
  };
6820
- if (!target) throw new ApplicationError("INVALID_STATE");
6821
- const targetId = target.kind === "existing" ? target.bindingId : null;
6822
- const candidate = resolution.candidates.find((value) => value.bindingId === targetId);
6823
- if (candidate && candidate.access !== item.access) {
6824
- const settings = await connection.preview(candidate.workId, item.access, signal);
6825
- return askSettings({
6826
- ...item,
6827
- target,
6828
- resolution
6829
- }, settings, null, snapshot);
6830
- }
6831
- const agentSources = await connection.checkAgentSources?.(item.artifact.agentSources, signal);
6832
- signal.throwIfAborted();
7253
+ if (!target) throw new ApplicationError("INVALID_STATE");
7254
+ const targetId = target.kind === "existing" ? target.bindingId : null;
7255
+ const candidate = resolution.candidates.find((value) => value.bindingId === targetId);
7256
+ if (candidate && candidate.access !== item.access) {
7257
+ const settings = await connection.preview(candidate.workId, item.access, signal);
7258
+ return askSettings({
7259
+ ...item,
7260
+ target,
7261
+ resolution
7262
+ }, settings, null, snapshot);
7263
+ }
7264
+ const agentSources = await connection.checkAgentSources?.(item.artifact.agentSources, signal);
7265
+ signal.throwIfAborted();
7266
+ return {
7267
+ ...item,
7268
+ target,
7269
+ resolution,
7270
+ status: "uploading",
7271
+ error: null,
7272
+ reservation: {
7273
+ ...agentSources?.length ? { agentSources } : {},
7274
+ work,
7275
+ target,
7276
+ bundleId: item.artifact.bundleId,
7277
+ createdAt: item.artifact.createdAt,
7278
+ title: item.artifact.title,
7279
+ sessionCount: item.artifact.sessionCount,
7280
+ access: item.access,
7281
+ outputRevision: item.artifact.revision,
7282
+ byteLength: item.artifact.byteLength,
7283
+ sha256: item.artifact.sha256,
7284
+ expectedAccessVersion: resolution.accessVersion,
7285
+ expectedScopeVersion: resolution.scopeVersion
7286
+ }
7287
+ };
7288
+ }
7289
+ /** Keep only the current Send's pending assessment IDs. New arrivals wait behind unpolled peers. */
7290
+ function createAssessmentPoll() {
7291
+ let owner = null;
7292
+ let sendId;
7293
+ let pending = [];
7294
+ return (send, works) => {
7295
+ const byId = new Map(works.map((work) => [work.workId, work]));
7296
+ if (send.subject !== owner || send.id !== sendId) pending = [];
7297
+ owner = send.subject;
7298
+ sendId = send.id;
7299
+ const order = new Set(pending.filter((id) => byId.has(id)));
7300
+ for (const id of byId.keys()) order.add(id);
7301
+ pending = [...order];
7302
+ const selected = pending.splice(0, 3);
7303
+ pending.push(...selected);
7304
+ return selected.flatMap((id) => byId.get(id) ?? []);
7305
+ };
7306
+ }
7307
+ const confirmedSettings = (item) => item.settings !== null && item.settingsConfirmed;
7308
+ const started = (item) => Number(item.status === "uploading");
7309
+ /**
7310
+ * Works that share a workspace path or a git remote: once one completes, Passport offers it as
7311
+ * the other's candidate, so the other's target no longer resolves the same (ADR 0235).
7312
+ */
7313
+ function related(left, right) {
7314
+ return left.artifact.sources.some((one) => right.artifact.sources.some((other) => one.pathHash !== null && one.pathHash === other.pathHash || one.remoteHashes.some((hash) => other.remoteHashes.includes(hash))));
7315
+ }
7316
+ /**
7317
+ * The Works one advance steps together. Upload work comes before assessment checks: up to three
7318
+ * Works, started uploads first, then the largest ZIP, so the slowest Work starts earliest.
7319
+ * Completing an upload moves the owner's scope version, which Passport accepts while a Work's
7320
+ * own target resolves the same (ADR 0235). A Work related to one already picked waits for the
7321
+ * next batch. A reserved Work whose target would not survive that steps alone, and so does a
7322
+ * confirmed linked Settings change, which moves the access version every reservation carries,
7323
+ * once no upload has started. Assessment checks change nothing and step up to three together.
7324
+ */
7325
+ function pickBatch(items, limit, poll) {
7326
+ const uploads = items.filter(sendsOnNextAdvance).toSorted((left, right) => started(right) - started(left) || right.artifact.byteLength - left.artifact.byteLength);
7327
+ const alone = uploads.find((item) => item.status === "uploading" && item.reservation !== null && item.resolution !== null && !keepsTarget(item.resolution, item.reservation)) ?? (uploads[0] && confirmedSettings(uploads[0]) ? uploads[0] : void 0);
7328
+ if (alone) return [alone];
7329
+ if (uploads.length) return uploads.filter((item) => !confirmedSettings(item)).reduce((batch, item) => batch.length < limit && !batch.some((picked) => related(picked, item)) ? [...batch, item] : batch, []);
7330
+ const assessing = items.filter((item) => item.status === "assessing");
7331
+ const first = assessing[0];
7332
+ if (!first) return [];
7333
+ if (confirmedSettings(first)) return [first];
7334
+ const checks = assessing.filter((item) => !confirmedSettings(item));
7335
+ return poll ? poll(checks) : checks.slice(0, 3);
7336
+ }
7337
+ function isCode(error, code) {
7338
+ return error instanceof ApplicationError && error.code === code;
7339
+ }
7340
+ /**
7341
+ * A step that ended without a definite answer and is safe to repeat: a request timeout or abort
7342
+ * that is not the Send's own cancellation, or a connection failure from `fetch`: `fetch failed`
7343
+ * before the response, `terminated` when the socket resets while the body is read. The Work keeps
7344
+ * its state, so the next advance repeats the same idempotent step (ADR 0197).
7345
+ */
7346
+ function isInterrupted(error) {
7347
+ if (!(error instanceof Error)) return false;
7348
+ if (error.name === "TimeoutError" || error.name === "AbortError") return true;
7349
+ return error instanceof TypeError && (error.message === "fetch failed" || error.message === "terminated");
7350
+ }
7351
+ //#endregion
7352
+ //#region apps/cli/src/account/journey/session-facts-cache.ts
7353
+ const keyFor = (journeyId, artifact) => JSON.stringify([
7354
+ journeyId,
7355
+ artifact.workId,
7356
+ artifact.revision,
7357
+ artifact.sha256
7358
+ ]);
7359
+ /** Sender-owned facts only. In-flight computations never enter the bounded cache. */
7360
+ function createSessionFactsCache(limit = 256) {
7361
+ const facts = /* @__PURE__ */ new Map();
7362
+ let owner = "";
7363
+ let generation = 0;
7364
+ let allowed = /* @__PURE__ */ new Set();
7365
+ return {
7366
+ retain(journeyId, send) {
7367
+ const nextOwner = JSON.stringify([journeyId, send?.id]);
7368
+ const ended = !send || [
7369
+ "completed",
7370
+ "blocked",
7371
+ "prepared"
7372
+ ].includes(send.status);
7373
+ if (owner !== nextOwner || ended) {
7374
+ facts.clear();
7375
+ generation += 1;
7376
+ }
7377
+ owner = nextOwner;
7378
+ allowed = new Set(ended || !send ? [] : send.items.filter((item) => !item.sessionAssembly && item.status !== "completed").map((item) => keyFor(journeyId, item.artifact)));
7379
+ for (const key of facts.keys()) if (!allowed.has(key)) facts.delete(key);
7380
+ },
7381
+ async get(journeyId, artifact, signal, compute) {
7382
+ signal.throwIfAborted();
7383
+ const key = keyFor(journeyId, artifact);
7384
+ const cached = facts.get(key);
7385
+ if (cached) return cached;
7386
+ const started = generation;
7387
+ const items = await compute();
7388
+ signal.throwIfAborted();
7389
+ if (started === generation && allowed.has(key)) {
7390
+ facts.set(key, items);
7391
+ while (facts.size > limit) {
7392
+ const oldest = facts.keys().next().value;
7393
+ if (oldest === void 0) break;
7394
+ facts.delete(oldest);
7395
+ }
7396
+ }
7397
+ return items;
7398
+ }
7399
+ };
7400
+ }
7401
+ //#endregion
7402
+ //#region apps/cli/src/account/journey/session-preparation.ts
7403
+ /** Shared facts retain no bytes; only upload steps acquire phase-local live iterators. */
7404
+ function createSessionPreparation(dependencies, facts) {
7405
+ const prepare = dependencies.artifacts.prepareSessions;
7406
+ const prepared = [];
7407
+ const sessions = async function* (journeyId, artifact, signal) {
7408
+ if (!prepare) {
7409
+ const original = dependencies.artifacts.sessions;
7410
+ if (original) yield* original(journeyId, artifact, signal);
7411
+ return;
7412
+ }
7413
+ const result = await prepare(journeyId, artifact, signal);
7414
+ prepared.push(result);
7415
+ for await (const item of result.items) {
7416
+ signal.throwIfAborted();
7417
+ yield item;
7418
+ }
7419
+ };
7420
+ const sessionFacts = async function* (journeyId, artifact, signal) {
7421
+ const items = await facts.get(journeyId, artifact, signal, async () => {
7422
+ const original = dependencies.artifacts.sessionFacts ?? dependencies.artifacts.sessions;
7423
+ const items = [];
7424
+ if (original) for await (const item of original(journeyId, artifact, signal)) {
7425
+ const { workerIdentity, nativeSessionId, relativePath, harness, lastAt, metadata, sha256, byteLength, crc32 } = item;
7426
+ items.push({
7427
+ ...workerIdentity ? { workerIdentity } : {},
7428
+ nativeSessionId,
7429
+ relativePath,
7430
+ harness,
7431
+ lastAt,
7432
+ metadata,
7433
+ sha256,
7434
+ byteLength,
7435
+ crc32
7436
+ });
7437
+ }
7438
+ return items;
7439
+ });
7440
+ for (const item of items) {
7441
+ signal.throwIfAborted();
7442
+ yield item;
7443
+ }
7444
+ };
7445
+ return {
7446
+ dependencies: {
7447
+ ...dependencies,
7448
+ artifacts: {
7449
+ ...dependencies.artifacts,
7450
+ sessions,
7451
+ sessionFacts
7452
+ }
7453
+ },
7454
+ async close() {
7455
+ await createCleanup().run(...prepared.map((result) => () => result.close()));
7456
+ }
7457
+ };
7458
+ }
7459
+ //#endregion
7460
+ //#region apps/cli/src/account/journey/session-metadata.ts
7461
+ /** The owner API uses this exact tuple; object key insertion order is not a wire dependency. */
7462
+ function sessionMetadataSha256(metadata) {
7463
+ return createHash("sha256").update(JSON.stringify([
7464
+ metadata.workspace.title,
7465
+ metadata.workspace.pathHash,
7466
+ metadata.workspace.remoteHashes,
7467
+ metadata.workspace.primaryRemoteHash,
7468
+ metadata.sourceSessionId,
7469
+ metadata.harness,
7470
+ metadata.lastAt,
7471
+ metadata.parts.map((part) => [
7472
+ part.path,
7473
+ part.byteLength,
7474
+ part.sha256,
7475
+ part.crc32,
7476
+ [
7477
+ part.native.role,
7478
+ part.native.sessionId,
7479
+ part.native.agentId,
7480
+ part.native.dispatchParentSessionId,
7481
+ part.native.fork ? [
7482
+ part.native.fork.parentSessionId,
7483
+ part.native.fork.position.kind,
7484
+ part.native.fork.position.value
7485
+ ] : null
7486
+ ]
7487
+ ])
7488
+ ])).digest("hex");
7489
+ }
7490
+ //#endregion
7491
+ //#region apps/cli/src/account/journey/receipt.ts
7492
+ function acceptReceipt(item, receipt) {
7493
+ if (receipt.logicalWorkId !== item.workId || receipt.bundleId !== item.artifact.bundleId || receipt.outputRevision !== item.artifact.revision || receipt.sha256 !== item.artifact.sha256 || receipt.byteLength !== item.artifact.byteLength || receipt.access !== item.access || item.receipt !== null && (receipt.receiptId !== item.receipt.receiptId || receipt.scanId !== item.receipt.scanId || receipt.bindingId !== item.receipt.bindingId) || item.target?.kind === "existing" && receipt.bindingId !== item.target.bindingId) throw new ApplicationError("ARTIFACT_IDENTITY_MISMATCH");
7494
+ const { upload: _upload, ...saved } = receipt;
7495
+ const success = receipt.state === "succeeded" && receipt.assessmentState === "succeeded" && receipt.passportIncluded && receipt.effectiveAccess === item.access;
7496
+ const failure = [
7497
+ "failed",
7498
+ "partial",
7499
+ "superseded"
7500
+ ].includes(receipt.state) || receipt.state === "succeeded" && !success;
6833
7501
  return {
6834
7502
  ...item,
6835
- target,
6836
- resolution,
6837
- status: "uploading",
6838
- error: null,
6839
- reservation: {
6840
- ...agentSources?.length ? { agentSources } : {},
6841
- work,
6842
- target,
6843
- bundleId: item.artifact.bundleId,
6844
- createdAt: item.artifact.createdAt,
6845
- title: item.artifact.title,
6846
- sessionCount: item.artifact.sessionCount,
6847
- access: item.access,
6848
- outputRevision: item.artifact.revision,
6849
- byteLength: item.artifact.byteLength,
6850
- sha256: item.artifact.sha256,
6851
- expectedAccessVersion: resolution.accessVersion,
6852
- expectedScopeVersion: resolution.scopeVersion
6853
- }
7503
+ receipt: saved,
7504
+ status: success ? "completed" : failure ? "blocked" : receipt.state === "awaiting_upload" ? "uploading" : "assessing",
7505
+ error: failure ? receipt.state === "succeeded" ? "PASSPORT_NOT_CURRENT" : "ASSESSMENT_INCOMPLETE" : null
6854
7506
  };
6855
7507
  }
6856
7508
  //#endregion
@@ -7012,51 +7664,6 @@ function renewed(item, error) {
7012
7664
  error
7013
7665
  };
7014
7666
  }
7015
- const confirmedSettings = (item) => item.settings !== null && item.settingsConfirmed;
7016
- const started = (item) => Number(item.status === "uploading");
7017
- /**
7018
- * Works that share a workspace path or a git remote: once one completes, Passport offers it as
7019
- * the other's candidate, so the other's target no longer resolves the same (ADR 0235).
7020
- */
7021
- function related(left, right) {
7022
- return left.artifact.sources.some((one) => right.artifact.sources.some((other) => one.pathHash !== null && one.pathHash === other.pathHash || one.remoteHashes.some((hash) => other.remoteHashes.includes(hash))));
7023
- }
7024
- /**
7025
- * The Works one advance steps together. Upload work comes before assessment checks: up to three
7026
- * Works, started uploads first, then the largest ZIP, so the slowest Work starts earliest.
7027
- * Completing an upload moves the owner's scope version, which Passport accepts while a Work's
7028
- * own target resolves the same (ADR 0235). A Work related to one already picked waits for the
7029
- * next batch. A reserved Work whose target would not survive that steps alone, and so does a
7030
- * confirmed linked Settings change, which moves the access version every reservation carries,
7031
- * once no upload has started. Assessment checks change nothing and step up to three together.
7032
- */
7033
- function pickBatch(items, now, limit) {
7034
- const uploads = items.filter((item) => item.status === "uploading" || item.status === "ready").toSorted((left, right) => started(right) - started(left) || right.artifact.byteLength - left.artifact.byteLength);
7035
- const alone = uploads.find((item) => item.status === "uploading" && item.reservation !== null && item.resolution !== null && !keepsTarget(item.resolution, item.reservation)) ?? (uploads[0] && confirmedSettings(uploads[0]) ? uploads[0] : void 0);
7036
- if (alone) return [alone];
7037
- if (uploads.length) return uploads.filter((item) => !confirmedSettings(item)).reduce((batch, item) => batch.length < limit && !batch.some((picked) => related(picked, item)) ? [...batch, item] : batch, []);
7038
- const assessing = items.filter((item) => item.status === "assessing");
7039
- const first = assessing[0];
7040
- if (!first) return [];
7041
- if (confirmedSettings(first)) return [first];
7042
- const checks = assessing.filter((item) => !confirmedSettings(item));
7043
- const start = Math.floor(now / 1e3) * 3 % checks.length;
7044
- return [...checks.slice(start), ...checks.slice(0, start)].slice(0, 3);
7045
- }
7046
- function isCode(error, code) {
7047
- return error instanceof ApplicationError && error.code === code;
7048
- }
7049
- /**
7050
- * A step that ended without a definite answer and is safe to repeat: a request timeout or abort
7051
- * that is not the Send's own cancellation, or a connection failure from `fetch`: `fetch failed`
7052
- * before the response, `terminated` when the socket resets while the body is read. The Work keeps
7053
- * its state, so the next advance repeats the same idempotent step (ADR 0197).
7054
- */
7055
- function isInterrupted(error) {
7056
- if (!(error instanceof Error)) return false;
7057
- if (error.name === "TimeoutError" || error.name === "AbortError") return true;
7058
- return error instanceof TypeError && (error.message === "fetch failed" || error.message === "terminated");
7059
- }
7060
7667
  const sessionBackoffMs = (attempt) => 1e3 * 2 ** (attempt - 1);
7061
7668
  const zeroByteBackoffMs = (count) => [
7062
7669
  15e3,
@@ -7071,7 +7678,7 @@ function retryDelay(error, fallback, now) {
7071
7678
  return Number.isFinite(delay) ? Math.max(fallback, delay) : fallback;
7072
7679
  }
7073
7680
  /** Reconcile retained intents; only byte-started failures charge the durable budget. */
7074
- async function attemptSession({ dependencies, original, session, key, remote, signal, guard, checkpoint }) {
7681
+ async function attemptSession({ dependencies, original, countCompletion, session, key, remote, signal, guard, checkpoint }) {
7075
7682
  const previous = original.sessionUpload?.key === key && original.sessionUpload.sha256 === session.sha256 ? original.sessionUpload : null;
7076
7683
  let failures = previous?.attempt ?? 0;
7077
7684
  let retryCount = previous?.retryCount ?? 0;
@@ -7124,12 +7731,12 @@ async function attemptSession({ dependencies, original, session, key, remote, si
7124
7731
  lastReported = Date.now();
7125
7732
  }
7126
7733
  });
7127
- await guard();
7128
- await remote.complete({
7129
- ...input,
7130
- metadata: session.metadata
7131
- }, signal);
7132
7734
  }
7735
+ await guard();
7736
+ await remote.complete({
7737
+ ...input,
7738
+ metadata: session.metadata
7739
+ }, signal);
7133
7740
  };
7134
7741
  try {
7135
7742
  if (previous === null) await reserveUploadComplete();
@@ -7143,6 +7750,10 @@ async function attemptSession({ dependencies, original, session, key, remote, si
7143
7750
  if (failures >= 3) throw new ApplicationError("SESSION_UPLOAD_EXHAUSTED");
7144
7751
  await reserveUploadComplete();
7145
7752
  }
7753
+ if (countCompletion) item = {
7754
+ ...item,
7755
+ storedSessions: (original.storedSessions ?? 0) + 1
7756
+ };
7146
7757
  transfer.bytesAcknowledged = session.byteLength;
7147
7758
  await guard();
7148
7759
  await save();
@@ -7182,6 +7793,7 @@ async function stepSessionWork(dependencies, journeyId, context, item, connectio
7182
7793
  if (!item.sessionAssembly && (item.status !== "uploading" || item.receipt || item.reservation)) return {
7183
7794
  ...item,
7184
7795
  receipt: null,
7796
+ storedSessions: item.sessionMembership ? item.storedSessions : 0,
7185
7797
  reservation: null,
7186
7798
  target: null,
7187
7799
  previousReceipts: item.receipt ? [...item.previousReceipts, item.receipt] : item.previousReceipts,
@@ -7215,7 +7827,7 @@ async function stepSessionWork(dependencies, journeyId, context, item, connectio
7215
7827
  const intent = item.sessionMembership;
7216
7828
  const assembly = await remote.assembly(intent.workId, signal);
7217
7829
  const conflict = async () => {
7218
- const current = await remote.inventory(signal);
7830
+ const current = await remote.work(intent.workId, signal);
7219
7831
  const revision = await remote.assembly(intent.workId, signal);
7220
7832
  await guard();
7221
7833
  return {
@@ -7224,7 +7836,7 @@ async function stepSessionWork(dependencies, journeyId, context, item, connectio
7224
7836
  error: "SESSION_MEMBERSHIP_CONFLICT",
7225
7837
  membershipConflict: {
7226
7838
  revision: revision?.revision ?? 0,
7227
- keys: current.works.find((work) => work.logicalWorkId === intent.workId)?.sessions.map((session) => session.key) ?? []
7839
+ keys: current?.sessions.map((session) => session.key) ?? []
7228
7840
  }
7229
7841
  };
7230
7842
  };
@@ -7241,7 +7853,7 @@ async function stepSessionWork(dependencies, journeyId, context, item, connectio
7241
7853
  const { workId, ...input } = intent;
7242
7854
  const result = await remote.membership(workId, input, signal);
7243
7855
  await guard();
7244
- if (migrate) {
7856
+ if (migrate || item.sessionMembershipMetadataVersion !== 1) {
7245
7857
  const { sessionMembership: _, ...rest } = item;
7246
7858
  return {
7247
7859
  ...rest,
@@ -7259,6 +7871,7 @@ async function stepSessionWork(dependencies, journeyId, context, item, connectio
7259
7871
  error: null
7260
7872
  };
7261
7873
  } catch (error) {
7874
+ if (error instanceof JourneyRemoteError && error.status === 413 && error.code === "work_session_capacity_exceeded") throw new ApplicationError("WORK_SESSION_CAPACITY_EXCEEDED");
7262
7875
  if (error instanceof JourneyRemoteError && error.status === 409) return conflict();
7263
7876
  throw error;
7264
7877
  }
@@ -7276,20 +7889,31 @@ async function stepSessionWork(dependencies, journeyId, context, item, connectio
7276
7889
  break;
7277
7890
  }
7278
7891
  }
7892
+ const ownKeys = /* @__PURE__ */ new Set();
7893
+ if (!recoverLegacy) item = {
7894
+ ...item,
7895
+ storedSessions: 0
7896
+ };
7279
7897
  for await (const session of sessions(journeyId, item.artifact, signal)) {
7280
7898
  const identity = await keys.add(session);
7899
+ ownKeys.add(identity.key);
7281
7900
  if (recoverLegacy && (identity.legacyKey !== pending?.key || session.sha256 !== pending?.sha256)) continue;
7282
7901
  const key = recoverLegacy && pending ? pending.key : identity.key;
7283
- if (stored.get(key)?.sha256 === session.sha256) {
7902
+ if (stored.get(key)?.sha256 === session.sha256 && stored.get(key)?.metadataSha256 === sessionMetadataSha256(session.metadata)) {
7284
7903
  if (recoverLegacy) return {
7285
7904
  ...item,
7286
7905
  sessionUpload: null
7287
7906
  };
7907
+ item = {
7908
+ ...item,
7909
+ storedSessions: (item.storedSessions ?? 0) + 1
7910
+ };
7288
7911
  continue;
7289
7912
  }
7290
7913
  item = await attemptSession({
7291
7914
  dependencies,
7292
7915
  original: item,
7916
+ countCompletion: !recoverLegacy,
7293
7917
  session,
7294
7918
  key,
7295
7919
  remote,
@@ -7301,21 +7925,32 @@ async function stepSessionWork(dependencies, journeyId, context, item, connectio
7301
7925
  }
7302
7926
  await guard();
7303
7927
  const assembly = await remote.assembly(item.workId, signal);
7304
- const current = (await remote.inventory(signal)).works.find((work) => work.logicalWorkId === item.workId);
7928
+ const current = await remote.work(item.workId, signal);
7305
7929
  if (current && current.access !== item.access) {
7306
- const resolution = await connection.resolve(resolveInput(item, context.bindingId), signal);
7307
- const [candidate] = resolution.candidates;
7308
- if (resolution.status === "blocked" || candidate?.access === "removed") throw new ApplicationError("WORK_REMOVED");
7309
- if (resolution.status !== "matched" || resolution.logicalWorkId !== item.workId || resolution.candidates.length !== 1 || !candidate || context.bindingId !== void 0 && candidate.bindingId !== context.bindingId) throw new ApplicationError("INVALID_STATE");
7310
- const settings = await connection.preview(candidate.workId, item.access, signal);
7930
+ if (current.access === "removed") throw new ApplicationError("WORK_REMOVED");
7931
+ const [ownKey] = ownKeys;
7932
+ if (!ownKey) throw new ApplicationError("INVALID_STATE");
7311
7933
  await guard();
7312
- await connection.confirm(settings, signal);
7934
+ return {
7935
+ ...item,
7936
+ status: "blocked",
7937
+ error: "SESSION_MOVE_ACCESS_REQUIRED",
7938
+ accessConflict: {
7939
+ sessionKey: ownKey,
7940
+ requestedAccess: item.access,
7941
+ accountWorkId: current.logicalWorkId,
7942
+ accountAccess: current.access,
7943
+ rule: "destination-access"
7944
+ },
7945
+ accessConflictSessionCount: ownKeys.size
7946
+ };
7313
7947
  }
7314
7948
  await guard();
7315
7949
  return {
7316
7950
  ...item,
7317
7951
  sessionUpload: null,
7318
7952
  sessionMembershipKeyVersion: 2,
7953
+ sessionMembershipMetadataVersion: 1,
7319
7954
  sessionMembership: {
7320
7955
  workId: item.workId,
7321
7956
  expectedRevision: assembly?.revision ?? 0,
@@ -7358,6 +7993,35 @@ async function saveSend(dependencies, snapshot, next, action, expectedJourneyRev
7358
7993
  at: dependencies.now()
7359
7994
  });
7360
7995
  }
7996
+ /** This transaction changes only the supplied transfer fields and durable cursor. */
7997
+ async function saveTransfer(dependencies, current, workId, patch) {
7998
+ const progress = await dependencies.storage.checkpointTransfer({
7999
+ journeyId: current.journey.journeyId,
8000
+ sendId: current.send.id,
8001
+ workId,
8002
+ expectedJourneyRevision: current.journey.revision,
8003
+ expectedSendRevision: current.send.revision,
8004
+ requestKey: randomUUID(),
8005
+ at: dependencies.now(),
8006
+ ...patch
8007
+ });
8008
+ if (progress.status !== "applied") throw new ApplicationError("REVISION_CONFLICT");
8009
+ return {
8010
+ journey: {
8011
+ ...current.journey,
8012
+ revision: progress.revision,
8013
+ lastEvent: progress.lastEvent
8014
+ },
8015
+ send: {
8016
+ ...current.send,
8017
+ revision: progress.sendRevision,
8018
+ items: current.send.items.map((item) => item.workId === workId ? {
8019
+ ...item,
8020
+ ...patch
8021
+ } : item)
8022
+ }
8023
+ };
8024
+ }
7361
8025
  function aggregate(send) {
7362
8026
  if (send.status === "paused") return send;
7363
8027
  const statuses = send.items.map((item) => item.status);
@@ -7366,17 +8030,6 @@ function aggregate(send) {
7366
8030
  status: statuses.length === 0 ? "blocked" : statuses.every((status) => status === "completed") ? "completed" : statuses.some((status) => status === "target" || status === "settings") ? "review" : statuses.some((status) => status === "ready" || status === "uploading") ? "sending" : statuses.some((status) => status === "assessing") ? "waiting" : "blocked"
7367
8031
  };
7368
8032
  }
7369
- /**
7370
- * A completed Send that Passport has not finished with: a Work is still assessed or reapplied
7371
- * (ADR 0233). A blocked Work waits on the person's Retry, not on Passport.
7372
- */
7373
- function awaitingPassport(send) {
7374
- return send.status === "completed" && send.items.some((item) => !["completed", "blocked"].includes(item.status));
7375
- }
7376
- /** The host continues confirmed uploads and unfinished Passport assessments without a panel. */
7377
- function hostDrives(send) {
7378
- return awaitingPassport(send) || send.confirmedAt != null && ["sending", "waiting"].includes(send.status);
7379
- }
7380
8033
  /** Explicit resume retries recoverable items and recaptures a pending sign-in or profile. */
7381
8034
  function resumeState(send) {
7382
8035
  if (send.status !== "completed" && send.items.length && send.items.every((item) => item.status === "completed") && send.items.some((item) => !item.sessionAssembly)) return {
@@ -7505,15 +8158,126 @@ async function captureProfile(connection, guard, signal, failure) {
7505
8158
  }
7506
8159
  }
7507
8160
  //#endregion
8161
+ //#region apps/cli/src/account/journey/remote-guard.ts
8162
+ /**
8163
+ * The class of every method. Adding a method to either remote without classifying it here fails
8164
+ * the type check, and the guarded remote exposes only the methods named below.
8165
+ */
8166
+ const connectionClasses = {
8167
+ assertCurrent: "read",
8168
+ syncProfile: "profile-sync",
8169
+ readProfile: "read",
8170
+ addressTaken: "read",
8171
+ status: "read",
8172
+ checkAgentSources: "agent-sources-check",
8173
+ resolve: "work-resolve",
8174
+ preview: "settings-preview",
8175
+ confirm: "settings-confirm",
8176
+ reserve: "work-reserve",
8177
+ complete: "work-complete",
8178
+ replace: "works-replace",
8179
+ saveProfile: "profile-save",
8180
+ ownedPassport: "read"
8181
+ };
8182
+ const sessionClasses = {
8183
+ inventory: "read",
8184
+ assessment: "read",
8185
+ work: "session-work",
8186
+ assembly: "session-assembly",
8187
+ reserve: "session-reserve",
8188
+ complete: "session-complete",
8189
+ membership: "session-membership"
8190
+ };
8191
+ /**
8192
+ * The one place that decides whether a Send-path call may send something about the person's
8193
+ * work (ADR 0284). Rules of this kind belong here, never in a caller; `mutation` is for the
8194
+ * rules to come.
8195
+ */
8196
+ function assertMayLeave(scope, mutation) {
8197
+ if (!mayLeave(scope, mutation)) throw new ApplicationError("JOURNEY_OBSOLETE");
8198
+ }
8199
+ /** `assertMayLeave` as a question, for a caller that must skip what the guard would refuse. */
8200
+ function mayLeave(scope, _mutation) {
8201
+ const atPassport = scope.item ? itemAtPassport(scope.item) : scope.send?.items.some(itemAtPassport) ?? false;
8202
+ return !(scope.journey && obsoleteJourney(scope.journey) && !atPassport);
8203
+ }
8204
+ /**
8205
+ * The remote a Send uses, closed by default: it holds only the methods classified above. A read
8206
+ * passes through; every other method asks `assertMayLeave` first. A refusal rejects like a failed
8207
+ * remote call, so the advance blocks only that Work.
8208
+ */
8209
+ function guardSendRemote(connection, scope) {
8210
+ const through = (kind, call) => kind === "read" ? call : (...args) => guardedSendMutation(scope, kind, () => call(...args));
8211
+ const work = connectionClasses;
8212
+ const { sessions, checkAgentSources } = connection;
8213
+ return {
8214
+ subject: connection.subject,
8215
+ assertCurrent: through(work.assertCurrent, connection.assertCurrent),
8216
+ syncProfile: through(work.syncProfile, connection.syncProfile),
8217
+ readProfile: through(work.readProfile, connection.readProfile),
8218
+ addressTaken: through(work.addressTaken, connection.addressTaken),
8219
+ status: through(work.status, connection.status),
8220
+ resolve: through(work.resolve, connection.resolve),
8221
+ preview: through(work.preview, connection.preview),
8222
+ confirm: through(work.confirm, connection.confirm),
8223
+ reserve: through(work.reserve, connection.reserve),
8224
+ complete: through(work.complete, connection.complete),
8225
+ replace: through(work.replace, connection.replace),
8226
+ saveProfile: through(work.saveProfile, connection.saveProfile),
8227
+ ownedPassport: through(work.ownedPassport, connection.ownedPassport),
8228
+ ...checkAgentSources ? { checkAgentSources: through(work.checkAgentSources, checkAgentSources) } : {},
8229
+ ...sessions ? { sessions: {
8230
+ inventory: through(sessionClasses.inventory, sessions.inventory),
8231
+ assessment: through(sessionClasses.assessment, sessions.assessment),
8232
+ work: through(sessionClasses.work, sessions.work),
8233
+ assembly: through(sessionClasses.assembly, sessions.assembly),
8234
+ reserve: through(sessionClasses.reserve, sessions.reserve),
8235
+ complete: through(sessionClasses.complete, sessions.complete),
8236
+ membership: through(sessionClasses.membership, sessions.membership)
8237
+ } } : {}
8238
+ };
8239
+ }
8240
+ /** A guarded call that is not a remote method: the byte upload to a signed address. */
8241
+ function guardSendCall(scope, mutation, call) {
8242
+ return guardedSendMutation(scope, mutation, call);
8243
+ }
8244
+ /** Each session's byte upload to a signed address asks the guard too (ADR 0284). */
8245
+ function guardSessionUploads(sessions, scope) {
8246
+ return async function* (journeyId, artifact, signal) {
8247
+ for await (const session of sessions(journeyId, artifact, signal)) yield {
8248
+ ...session,
8249
+ upload: (upload, uploadSignal, progress) => guardedSendMutation(scope, "session-upload", () => session.upload(upload, uploadSignal, progress))
8250
+ };
8251
+ };
8252
+ }
8253
+ /** Named so a test can tell a guarded call from one that bypassed the guard. */
8254
+ function guardedSendMutation(scope, mutation, call) {
8255
+ try {
8256
+ assertMayLeave(scope, mutation);
8257
+ } catch (error) {
8258
+ return Promise.reject(error);
8259
+ }
8260
+ return call();
8261
+ }
8262
+ //#endregion
7508
8263
  //#region apps/cli/src/account/journey/advance.ts
7509
8264
  /** A Work Passport is done with: assessed, or stopped unless Resume retries it. */
7510
8265
  const settled = (item) => ["completed", "blocked"].includes(item.status);
8266
+ /** The byte upload to a signed address is a guarded call too (ADR 0284). */
8267
+ function guardedContext(context, scope, checksumUploads) {
8268
+ const upload = context.upload;
8269
+ return {
8270
+ ...context,
8271
+ checksumUploads,
8272
+ upload: (artifact, signed, signal) => guardSendCall(scope, "work-upload", () => upload(artifact, signed, signal))
8273
+ };
8274
+ }
7511
8275
  /** Stored state equality: key order and explicit undefined fields do not count. */
7512
8276
  const sameJson = (left, right) => isDeepStrictEqual(JSON.parse(JSON.stringify(left)), JSON.parse(JSON.stringify(right)));
7513
8277
  /** The stored Send still holds exactly this Work state, whatever else changed meanwhile. */
7514
8278
  function sameItem(items, item) {
7515
8279
  const current = items.find((value) => value.workId === item.workId);
7516
- return current !== void 0 && sameJson(current, item);
8280
+ return current === item || current !== void 0 && sameJson(current, item);
7517
8281
  }
7518
8282
  /** Shared sign-in, account and per-item rules for native Send and Manual upload. */
7519
8283
  async function advanceItems(host, signal) {
@@ -7555,7 +8319,7 @@ async function advanceItems(host, signal) {
7555
8319
  });
7556
8320
  }
7557
8321
  if (send.subject === null || send.status === "sign-in") {
7558
- const { parallelCompletes: captured, ...profile } = await host.profile(connection, signal);
8322
+ const { parallelCompletes: captured, ...profile } = await host.profile(guardSendRemote(connection, host.scope(null)), signal);
7559
8323
  const parallelCompletes = send.subject === null ? captured : send.parallelCompletes && captured;
7560
8324
  const prepared = {
7561
8325
  ...send,
@@ -7566,17 +8330,16 @@ async function advanceItems(host, signal) {
7566
8330
  else delete prepared.parallelCompletes;
7567
8331
  return host.save(prepared.status === "sending" ? aggregate(prepared) : prepared);
7568
8332
  }
7569
- const blocked = await host.preflight?.(connection, signal);
8333
+ const blocked = await host.preflight?.(guardSendRemote(connection, host.scope(null)), signal);
7570
8334
  if (blocked) return blocked;
7571
- const batch = host.batch?.() ?? pickBatch(send.items, Date.now(), send.parallelCompletes ? 3 : 1);
8335
+ const batch = host.batch?.() ?? pickBatch(send.items, send.parallelCompletes ? 3 : 1, host.poll?.bind(void 0, send));
7572
8336
  if (!batch.length) return host.applied();
7573
8337
  const busy = Math.max(0, ...batch.map((item) => item.busyRefusals ?? 0));
7574
8338
  if (busy) await wait(busyDelay(busy), signal);
7575
8339
  const checksumUploads = !send.items.some((item) => item.checksumUploadRefused);
7576
- const results = await Promise.allSettled(batch.map((item) => (host.step ?? stepItem)({
7577
- ...host.context(item),
7578
- checksumUploads
7579
- }, item, connection, signal)));
8340
+ const run = (item) => (host.step ?? stepItem)(guardedContext(host.context(item), host.scope(item), checksumUploads), item, guardSendRemote(connection, host.scope(item)), signal);
8341
+ const refill = host.refill;
8342
+ const results = refill && batch.length > 3 ? await runSessionQueue(batch, run, () => refill(guardSendRemote(connection, host.scope(null)), signal), signal) : await Promise.allSettled(batch.map(run));
7580
8343
  const failures = results.flatMap((result) => result.status === "rejected" ? [result] : []);
7581
8344
  if (failures.some(({ reason }) => isCode(reason, "REVISION_CONFLICT") || isCode(reason, "SEND_SCOPE_INVALIDATED"))) return host.stale();
7582
8345
  const first = failures[0];
@@ -7587,7 +8350,7 @@ async function advanceItems(host, signal) {
7587
8350
  const next = /* @__PURE__ */ new Map();
7588
8351
  for (const [index, result] of results.entries()) {
7589
8352
  const item = batch[index];
7590
- if (!item) continue;
8353
+ if (!item || !result) continue;
7591
8354
  if (result.status === "fulfilled") {
7592
8355
  next.set(item.workId, result.value);
7593
8356
  continue;
@@ -7656,6 +8419,44 @@ async function advanceItems(host, signal) {
7656
8419
  return blockSend(host, error, signal);
7657
8420
  }
7658
8421
  }
8422
+ /** Every queued Work was admitted as independent at preflight. Recheck delayed slots before use. */
8423
+ async function runSessionQueue(batch, run, admit, signal) {
8424
+ const results = [];
8425
+ let next = 0;
8426
+ let stopped = false;
8427
+ let admission;
8428
+ async function worker() {
8429
+ while (!stopped && next < batch.length) {
8430
+ const index = next++;
8431
+ const item = batch[index];
8432
+ if (!item) return;
8433
+ try {
8434
+ if (index >= 3) {
8435
+ admission ??= admit().finally(() => {
8436
+ admission = void 0;
8437
+ });
8438
+ if (!await admission) stopped = true;
8439
+ if (stopped) return;
8440
+ }
8441
+ signal.throwIfAborted();
8442
+ const value = await run(item);
8443
+ results[index] = {
8444
+ status: "fulfilled",
8445
+ value
8446
+ };
8447
+ if (value.status === "blocked") stopped = true;
8448
+ } catch (reason) {
8449
+ stopped = true;
8450
+ results[index] = {
8451
+ status: "rejected",
8452
+ reason
8453
+ };
8454
+ }
8455
+ }
8456
+ }
8457
+ await Promise.all(Array.from({ length: 3 }, worker));
8458
+ return results;
8459
+ }
7659
8460
  /** An unexpected failure blocks the whole send with a safe code; abort keeps propagating. */
7660
8461
  function blockSend(host, error, signal) {
7661
8462
  if (signal.aborted) throw error;
@@ -7699,35 +8500,47 @@ function completeAtUpload(send, sessions) {
7699
8500
  }
7700
8501
  async function advanceSend(dependencies, address, signal, chain) {
7701
8502
  let snapshot = requireSend(await dependencies.storage.loadSend(address.journeyId, address.sendId));
7702
- const sessionFacts = chain?.sessionFacts ?? createSessionFactsCache();
8503
+ const sessionFacts = chain.sessionFacts;
7703
8504
  sessionFacts.retain(address.journeyId, snapshot.send);
7704
- if (chain) chain.revision = snapshot.send.revision;
8505
+ chain.revision = snapshot.send.revision;
7705
8506
  if ((await dependencies.storage.loadSend(address.journeyId))?.send?.id !== snapshot.send.id) return {
7706
8507
  ...snapshot,
7707
8508
  status: "stale"
7708
8509
  };
7709
8510
  const completed = snapshot.send.status === "completed";
7710
- if (snapshot.journey.phase === "paused" || !snapshot.send.confirmedAt || ["paused", "prepared"].includes(snapshot.send.status) || completed && snapshot.send.items.every(settled)) return {
8511
+ if (!advanceStepsSend(snapshot.journey, snapshot.send)) return {
7711
8512
  ...snapshot,
7712
8513
  status: "applied"
7713
8514
  };
7714
8515
  const checkpoints = /* @__PURE__ */ new Map();
7715
8516
  const fulfilled = /* @__PURE__ */ new Set();
7716
8517
  let saving = Promise.resolve();
8518
+ let validated;
7717
8519
  async function active(item) {
7718
- const current = requireSend(await dependencies.storage.loadSend(address.journeyId));
7719
- if (current.send.id !== snapshot.send.id || !sameItem(current.send.items, checkpoints.get(item.workId) ?? item) || current.send.status === "paused" || current.journey.phase === "paused") throw new ApplicationError("REVISION_CONFLICT");
7720
- assertScope(current.journey, current.send);
8520
+ const observed = snapshot;
8521
+ const isCurrent = (candidate) => dependencies.storage.isCurrentSend(address.journeyId, candidate.send.id, candidate.journey.revision);
8522
+ let current = observed;
8523
+ if (!await isCurrent(observed)) {
8524
+ current = requireSend(await dependencies.storage.loadSend(address.journeyId));
8525
+ if (current.send.id !== observed.send.id || !await isCurrent(current)) throw new ApplicationError("REVISION_CONFLICT");
8526
+ }
8527
+ if (!sameItem(current.send.items, checkpoints.get(item.workId) ?? item) || current.send.status === "paused" || current.journey.phase === "paused") throw new ApplicationError("REVISION_CONFLICT");
8528
+ if (current !== validated) {
8529
+ assertScope(current.journey, current.send);
8530
+ validated = current;
8531
+ }
7721
8532
  return current;
7722
8533
  }
7723
8534
  const save = (proposed) => {
7724
8535
  const next = {
7725
8536
  ...proposed,
8537
+ error: proposed.status === "blocked" && !proposed.error && proposed.contentConflict && proposed.items.some((item) => item.error === "SESSION_CONTENT_CONFLICT") ? "SESSION_CONTENT_CONFLICT" : proposed.error,
7726
8538
  items: proposed.items.map((item) => {
7727
8539
  const checkpoint = checkpoints.get(item.workId);
7728
8540
  return checkpoint && !fulfilled.has(item.workId) ? {
7729
8541
  ...checkpoint,
7730
8542
  ...item,
8543
+ ...checkpoint.storedSessions !== void 0 ? { storedSessions: checkpoint.storedSessions } : {},
7731
8544
  sessionUpload: checkpoint.sessionUpload
7732
8545
  } : item;
7733
8546
  })
@@ -7743,6 +8556,7 @@ async function advanceSend(dependencies, address, signal, chain) {
7743
8556
  const send = withoutQuestions(snapshot.send);
7744
8557
  try {
7745
8558
  assertScope(snapshot.journey, snapshot.send);
8559
+ validated = snapshot;
7746
8560
  } catch (error) {
7747
8561
  if (completed) return save({
7748
8562
  ...snapshot.send,
@@ -7752,10 +8566,21 @@ async function advanceSend(dependencies, address, signal, chain) {
7752
8566
  error: "SEND_SCOPE_CHANGED"
7753
8567
  })
7754
8568
  });
8569
+ const invalidated = safeCode(error) === "SEND_SCOPE_INVALIDATED";
8570
+ const answered = (item) => {
8571
+ const work = snapshot.journey.works.find((work) => work.id === item.workId);
8572
+ return !work || work.access !== item.access;
8573
+ };
8574
+ const answeredSessions = new Set(snapshot.send.items.flatMap((item) => item.error === "SESSION_MOVE_ACCESS_REQUIRED" && item.accessConflict && answered(item) ? [item.accessConflict.sessionKey] : []));
7755
8575
  return blockSend({
7756
- send: safeCode(error) === "SEND_SCOPE_INVALIDATED" ? {
8576
+ send: invalidated ? {
7757
8577
  ...snapshot.send,
7758
- items: snapshot.send.items.filter((item) => !["SESSION_MOVE_ACCESS_REQUIRED", "SESSION_MOVE_ACCESS_HELD"].includes(item.error ?? ""))
8578
+ items: snapshot.send.items.filter((item) => {
8579
+ if (item.error === "SESSION_MOVE_ACCESS_HELD") return false;
8580
+ if (item.error !== "SESSION_MOVE_ACCESS_REQUIRED") return true;
8581
+ if (item.accessConflict?.accountAccess === null && answeredSessions.has(item.accessConflict.sessionKey)) return false;
8582
+ return !answered(item);
8583
+ })
7759
8584
  } : snapshot.send,
7760
8585
  failure: dependencies.failure,
7761
8586
  save
@@ -7767,24 +8592,83 @@ async function advanceSend(dependencies, address, signal, chain) {
7767
8592
  };
7768
8593
  if (send !== snapshot.send) return save(send);
7769
8594
  let independentItems = [];
8595
+ let queuedIds = [];
7770
8596
  const preparation = createSessionPreparation(dependencies, sessionFacts);
8597
+ const sessionStepDependencies = (item) => {
8598
+ const { artifacts } = preparation.dependencies;
8599
+ if (!artifacts.sessions) return preparation.dependencies;
8600
+ const scope = {
8601
+ journey: snapshot.journey,
8602
+ send: snapshot.send,
8603
+ item
8604
+ };
8605
+ return {
8606
+ ...preparation.dependencies,
8607
+ artifacts: {
8608
+ ...artifacts,
8609
+ sessions: guardSessionUploads(artifacts.sessions, scope)
8610
+ }
8611
+ };
8612
+ };
8613
+ let queueActive = false;
8614
+ async function readAdmission(connection, currentItems, signal) {
8615
+ if (!connection.sessions) throw new ApplicationError("INVALID_STATE");
8616
+ const inventory = await connection.sessions.inventory(signal);
8617
+ const conflicts = await sessionAccessBlocked(preparation.dependencies, snapshot.journey.journeyId, currentItems, inventory, signal);
8618
+ return {
8619
+ conflicts,
8620
+ independent: conflicts.length ? [] : await sessionIndependentItems(preparation.dependencies, snapshot.journey.journeyId, currentItems, inventory, signal)
8621
+ };
8622
+ }
8623
+ let stopObservation;
7771
8624
  const cleanup = createCleanup();
7772
8625
  try {
7773
8626
  return await advanceItems({
7774
8627
  send,
8628
+ poll: chain.poll,
7775
8629
  failure: dependencies.failure,
7776
8630
  ...usesSessionSend(dependencies, send) ? {
7777
8631
  batch: () => {
7778
8632
  const limit = send.parallelCompletes ? 3 : 1;
7779
- return independentItems.length ? pickBatch(independentItems, Date.now(), limit) : pickBatch(send.items, Date.now(), limit);
8633
+ const candidates = independentItems.length ? independentItems : send.items;
8634
+ const planned = pickBatch(candidates, limit === 3 && independentItems.length ? candidates.length : limit, (works) => chain.poll(send, works));
8635
+ queueActive = limit === 3 && planned.length > limit && planned.some((item) => item.status === "uploading") && planned.every((item) => !item.busyRefusals && !item.sessionUpload?.nextAttemptAt);
8636
+ if (queueActive) queuedIds = planned.map((item) => item.workId);
8637
+ return queueActive ? planned : planned.slice(0, limit);
7780
8638
  },
7781
8639
  preflight: async (connection, preflightSignal) => {
7782
8640
  if (!connection.sessions) throw new ApplicationError("INVALID_STATE");
7783
- if (!send.items.some((item) => item.status === "uploading" && !item.sessionAssembly && !item.receipt && !item.reservation)) return null;
7784
- const inventory = await connection.sessions.inventory(preflightSignal);
7785
- const conflicts = await sessionAccessBlocked(preparation.dependencies, snapshot.journey.journeyId, send.items, inventory, preflightSignal);
7786
- if (conflicts.length === 0) {
7787
- independentItems = await sessionIndependentItems(preparation.dependencies, snapshot.journey.journeyId, send.items, inventory, preflightSignal);
8641
+ const checked = send.items.filter((item) => mayLeave({
8642
+ journey: snapshot.journey,
8643
+ send: snapshot.send,
8644
+ item
8645
+ }, "session-membership"));
8646
+ if (!checked.some((item) => item.status === "uploading" && !item.sessionAssembly && !item.receipt && !item.reservation)) return null;
8647
+ let admission;
8648
+ try {
8649
+ admission = await readAdmission(connection, checked, preflightSignal);
8650
+ } catch (error) {
8651
+ if (!(error instanceof SessionContentConflict)) throw error;
8652
+ await connection.assertCurrent(preflightSignal);
8653
+ dependencies.failure(error.code, error);
8654
+ const items = send.items.map((item) => error.affectedWorkIds.has(item.workId) && !["completed", "blocked"].includes(item.status) && !item.sessionAssembly ? {
8655
+ ...item,
8656
+ status: "blocked",
8657
+ error: error.code
8658
+ } : item);
8659
+ const next = aggregate({
8660
+ ...send,
8661
+ items,
8662
+ contentConflict: error.contentConflict
8663
+ });
8664
+ return save({
8665
+ ...next,
8666
+ error: next.status === "blocked" ? error.code : null
8667
+ });
8668
+ }
8669
+ const { conflicts, independent } = admission;
8670
+ if (!conflicts.length) {
8671
+ independentItems = independent;
7788
8672
  return null;
7789
8673
  }
7790
8674
  await connection.assertCurrent(preflightSignal);
@@ -7803,7 +8687,8 @@ async function advanceSend(dependencies, address, signal, chain) {
7803
8687
  if (item.sessionAssembly || item.status === "completed") return item;
7804
8688
  const { accessConflict: _previous, accessConflictSessionCount: _count, ...current } = item;
7805
8689
  const itemConflicts = conflicts.filter((entry) => entry.workId === item.workId);
7806
- const conflict = itemConflicts[0];
8690
+ const conflict = itemConflicts.find((entry) => entry.accountAccess !== null) ?? itemConflicts[0];
8691
+ if (item.status === "blocked" && item.error === "SESSION_CONTENT_CONFLICT") return item;
7807
8692
  if (!conflict) return {
7808
8693
  ...current,
7809
8694
  status: "blocked",
@@ -7820,35 +8705,74 @@ async function advanceSend(dependencies, address, signal, chain) {
7820
8705
  })
7821
8706
  });
7822
8707
  },
7823
- step: (context, item, connection, stepSignal) => stepSessionWork(preparation.dependencies, snapshot.journey.journeyId, context, item, connection, stepSignal, (next) => {
7824
- const pending = saving.then(async () => {
7825
- const current = await active(item);
7826
- if (current.send.revision !== snapshot.send.revision || current.journey.revision !== snapshot.journey.revision) throw new ApplicationError("REVISION_CONFLICT");
7827
- stepSignal.throwIfAborted();
7828
- const result = await saveSend(dependencies, current, {
7829
- ...current.send,
7830
- items: current.send.items.map((value) => value.workId === next.workId ? next : value)
7831
- });
7832
- if (result.status === "stale") throw new ApplicationError("REVISION_CONFLICT");
7833
- snapshot = requireSend(result);
7834
- const saved = snapshot.send.items.find((value) => value.workId === next.workId);
7835
- if (!saved) throw new ApplicationError("INVALID_STATE");
7836
- checkpoints.set(next.workId, saved);
8708
+ refill: async (connection, refillSignal) => {
8709
+ let checked;
8710
+ try {
8711
+ checked = await readAdmission(connection, snapshot.send.items, refillSignal);
8712
+ } catch {
8713
+ refillSignal.throwIfAborted();
8714
+ return false;
8715
+ }
8716
+ return !checked.conflicts.length && queuedIds.every((workId) => {
8717
+ return snapshot.send.items.find((entry) => entry.workId === workId)?.sessionAssembly || checked.independent.some((entry) => entry.workId === workId);
7837
8718
  });
7838
- saving = pending.catch(() => void 0);
7839
- return pending;
7840
- }).then(({ busyRefusals: _busy, ...result }) => {
8719
+ },
8720
+ step: async (context, item, connection, stepSignal) => {
8721
+ if (!stopObservation && (item.status === "ready" || item.status === "uploading")) stopObservation = dependencies.observeUpload?.(connection, () => snapshot.send, stepSignal);
8722
+ const checkpoint = (next, transfer = false) => {
8723
+ const pending = saving.then(async () => {
8724
+ const current = await active(item);
8725
+ if (current.send.revision !== snapshot.send.revision || current.journey.revision !== snapshot.journey.revision) throw new ApplicationError("REVISION_CONFLICT");
8726
+ stepSignal.throwIfAborted();
8727
+ if (transfer && next.sessionUpload) {
8728
+ snapshot = await saveTransfer(dependencies, current, next.workId, {
8729
+ sessionUpload: next.sessionUpload,
8730
+ ...next.storedSessions === void 0 ? {} : { storedSessions: next.storedSessions }
8731
+ });
8732
+ validated = snapshot;
8733
+ } else {
8734
+ const result = await saveSend(dependencies, current, {
8735
+ ...current.send,
8736
+ items: current.send.items.map((value) => value.workId === next.workId ? next : value)
8737
+ });
8738
+ if (result.status === "stale") throw new ApplicationError("REVISION_CONFLICT");
8739
+ snapshot = requireSend(result);
8740
+ }
8741
+ const saved = snapshot.send.items.find((value) => value.workId === next.workId);
8742
+ if (!saved) throw new ApplicationError("INVALID_STATE");
8743
+ checkpoints.set(next.workId, saved);
8744
+ });
8745
+ saving = pending.catch(() => void 0);
8746
+ return pending;
8747
+ };
8748
+ const step = (current) => stepSessionWork(sessionStepDependencies(current), snapshot.journey.journeyId, context, current, connection, stepSignal, (next) => checkpoint(next, true));
8749
+ let result = await step(item);
8750
+ if (queueActive && item.status === "ready" && result.status === "uploading" && mayLeave({
8751
+ journey: snapshot.journey,
8752
+ send: snapshot.send,
8753
+ item: result
8754
+ }, "session-reserve")) {
8755
+ await checkpoint(result);
8756
+ result = await step(result);
8757
+ }
8758
+ if (queueActive && !sameJson(result, checkpoints.get(item.workId) ?? item)) await checkpoint(result);
7841
8759
  fulfilled.add(item.workId);
7842
- return result;
7843
- })
8760
+ const { busyRefusals: _busy, ...finished } = result;
8761
+ return finished;
8762
+ }
7844
8763
  } : {},
7845
- connect: chain?.connect ?? dependencies.auth.connect,
8764
+ connect: chain.connect,
8765
+ scope: (item) => ({
8766
+ journey: snapshot.journey,
8767
+ send: snapshot.send,
8768
+ item
8769
+ }),
7846
8770
  profile: (connection, profileSignal) => prepareProfile(dependencies, snapshot, connection, profileSignal),
7847
8771
  context: (item) => {
7848
8772
  const binding = snapshot.journey.works.find((value) => value.id === item.workId)?.binding;
7849
8773
  return {
7850
8774
  snapshot: true,
7851
- ...chain ? { fresh: chain.fresh } : {},
8775
+ fresh: chain.fresh,
7852
8776
  active: async () => {
7853
8777
  await active(item);
7854
8778
  },
@@ -7874,6 +8798,7 @@ async function advanceSend(dependencies, address, signal, chain) {
7874
8798
  cleanup.failed(error);
7875
8799
  throw error;
7876
8800
  } finally {
8801
+ stopObservation?.();
7877
8802
  await cleanup.run(() => preparation.close());
7878
8803
  }
7879
8804
  }
@@ -7944,6 +8869,7 @@ function pathHash(item) {
7944
8869
  /** Manual upload: native Send's commands and recovery rules over one completed result's ZIPs. */
7945
8870
  function createManualUploader(dependencies) {
7946
8871
  const { storage } = dependencies;
8872
+ const poll = createAssessmentPoll();
7947
8873
  const running = /* @__PURE__ */ new Map();
7948
8874
  function save(prior, next, action) {
7949
8875
  return storage.commit({
@@ -8082,7 +9008,11 @@ function createManualUploader(dependencies) {
8082
9008
  error: null
8083
9009
  };
8084
9010
  if (connection) try {
8085
- profile = await captureProfile(connection, guard(upload), signal, dependencies.failure);
9011
+ profile = await captureProfile(guardSendRemote(connection, {
9012
+ journey: null,
9013
+ send: upload,
9014
+ item: null
9015
+ }), guard(upload), signal, dependencies.failure);
8086
9016
  } catch (error) {
8087
9017
  if (error instanceof ApplicationError && error.code === "REVISION_CONFLICT") return save(upload, upload, input);
8088
9018
  throw error;
@@ -8161,12 +9091,18 @@ function createManualUploader(dependencies) {
8161
9091
  let bindings = [];
8162
9092
  return advanceItems({
8163
9093
  send: upload,
9094
+ poll,
8164
9095
  failure: dependencies.failure,
8165
9096
  save: (next) => save(upload, next),
8166
9097
  connect: async (connectSignal) => {
8167
9098
  bindings = await storage.bindings();
8168
9099
  return dependencies.auth.connect(connectSignal);
8169
9100
  },
9101
+ scope: (item) => ({
9102
+ journey: null,
9103
+ send: upload,
9104
+ item
9105
+ }),
8170
9106
  profile: (connection, profileSignal) => captureProfile(connection, guard(upload), profileSignal, dependencies.failure),
8171
9107
  context: (item) => ({
8172
9108
  active: guard(upload, item),
@@ -9383,6 +10319,7 @@ function createJourneyFiles(storage) {
9383
10319
  return async (journeyId, workId, signal) => {
9384
10320
  const state = await storage.current(journeyId);
9385
10321
  if (!state?.consent) throw new ApplicationError("CONFIRMATION_REQUIRED");
10322
+ if (state.phase === "completed" && state.outputVersion !== 1) throw new ApplicationError("SCOPE_NOT_READY");
9386
10323
  assertJourneyPrivacyReady(state, true);
9387
10324
  if (state.phase === "paused") throw new ApplicationError("OPERATION_CANCELED");
9388
10325
  const work = state.works.find((item) => item.id === workId);
@@ -9417,6 +10354,7 @@ function createJourneyFiles(storage) {
9417
10354
  const sendRecoveryCodes = [
9418
10355
  "SESSION_MEMBERSHIP_CONFLICT",
9419
10356
  "SESSION_UPLOAD_EXHAUSTED",
10357
+ "WORK_SESSION_CAPACITY_EXCEEDED",
9420
10358
  "LEGACY_SEND_RECOVERY_REQUIRED",
9421
10359
  "ASSESSMENT_INCOMPLETE",
9422
10360
  "SESSION_PUBLICATION_FENCE_REQUIRED"
@@ -9472,10 +10410,14 @@ async function requestSend(dependencies, raw, hostId, source) {
9472
10410
  if (!hostId) throw new ApplicationError("INVALID_TRANSITION");
9473
10411
  const included = current.journey.works.filter(isIncludedWork);
9474
10412
  if (!sameIds$1(input.included.map((work) => work.workId), included.map((work) => work.id)) || !input.included.every((item) => included.some((work) => work.id === item.workId && work.access === item.access && work.accessConfirmed)) || !sameIds$1(input.omittedWorkIds, current.journey.works.filter((work) => !isIncludedWork(work)).map((work) => work.id))) throw new ApplicationError("CONFIRMATION_REQUIRED");
9475
- if (!binding) binding = {
9476
- subject: (await dependencies.auth.connect(new AbortController().signal))?.subject ?? null,
9477
- at: dependencies.now()
9478
- };
10413
+ if (!binding) {
10414
+ const connection = await dependencies.auth.connect(new AbortController().signal);
10415
+ if (!connection) throw new ApplicationError(dependencies.auth.configured ? "ACCOUNT_SIGNED_OUT" : "ACCOUNT_NOT_CONFIGURED");
10416
+ binding = {
10417
+ subject: connection.subject,
10418
+ at: dependencies.now()
10419
+ };
10420
+ }
9479
10421
  const { subject, at } = binding;
9480
10422
  const result = await dependencies.storage.commitSend({
9481
10423
  journeyId: input.journeyId,
@@ -9537,10 +10479,11 @@ async function requestError(dependencies, send, hostId, signal) {
9537
10479
  * next phase exactly as between advances. The advance returns after three phases or once no
9538
10480
  * picked Work is mid-upload, so later Works continue from the caller's next advance.
9539
10481
  */
9540
- async function advanceWork(dependencies, address, signal, phases = 3, sessionFacts = createSessionFactsCache()) {
10482
+ async function advanceWork(dependencies, address, signal, phases = 3, sessionFacts = createSessionFactsCache(), poll = createAssessmentPoll()) {
9541
10483
  let connection;
9542
10484
  const chain = {
9543
10485
  sessionFacts,
10486
+ poll,
9544
10487
  fresh: /* @__PURE__ */ new Map(),
9545
10488
  async connect(connectSignal) {
9546
10489
  if (connection === void 0) connection = await dependencies.auth.connect(connectSignal);
@@ -9555,7 +10498,102 @@ async function advanceWork(dependencies, address, signal, phases = 3, sessionFac
9555
10498
  /** The phase saved progress and left its Work mid-upload, with nothing else to report. */
9556
10499
  function uploading(result, before) {
9557
10500
  const send = result.send;
9558
- return result.status === "applied" && send !== null && before !== void 0 && send.revision > before && send.status === "sending" && send.error === null && pickBatch(send.items, Date.now(), send.parallelCompletes ? 3 : 1)[0]?.status === "uploading";
10501
+ return result.status === "applied" && send !== null && before !== void 0 && send.revision > before && send.status === "sending" && send.error === null && pickBatch(send.items, send.parallelCompletes ? 3 : 1)[0]?.status === "uploading";
10502
+ }
10503
+ //#endregion
10504
+ //#region apps/cli/src/account/journey/upload-observer.ts
10505
+ const intervalMs = 15e3;
10506
+ const budget = 3;
10507
+ /** A tab polls only the in-flight upload owner's read capability. No store or upload-slot acquisition. */
10508
+ function createUploadObserver() {
10509
+ let active;
10510
+ let retained = "";
10511
+ let cursor = 0;
10512
+ let next = 0;
10513
+ let reading;
10514
+ const observations = /* @__PURE__ */ new Map();
10515
+ function start(connection, current, signal) {
10516
+ const send = current();
10517
+ const scope = JSON.stringify([
10518
+ send.journeyId,
10519
+ send.id,
10520
+ send.subject
10521
+ ]);
10522
+ if (scope !== retained) {
10523
+ observations.clear();
10524
+ cursor = 0;
10525
+ next = 0;
10526
+ retained = scope;
10527
+ }
10528
+ const stop = new AbortController();
10529
+ const flight = {
10530
+ connection,
10531
+ current,
10532
+ signal: AbortSignal.any([signal, stop.signal])
10533
+ };
10534
+ active = flight;
10535
+ return () => {
10536
+ stop.abort();
10537
+ if (active === flight) active = void 0;
10538
+ };
10539
+ }
10540
+ function accepted(send) {
10541
+ return send.items.filter((item) => item.status === "assessing" && item.sessionAssembly);
10542
+ }
10543
+ async function refresh(flight) {
10544
+ const remote = flight.connection.sessions;
10545
+ if (!remote) return;
10546
+ const items = accepted(flight.current());
10547
+ if (!items.length) return;
10548
+ const startAt = cursor % items.length;
10549
+ const selected = [...items.slice(startAt), ...items.slice(0, startAt)].slice(0, budget);
10550
+ const signal = AbortSignal.any([flight.signal, AbortSignal.timeout(8e3)]);
10551
+ for (const item of selected) {
10552
+ const revision = item.sessionAssembly?.revision;
10553
+ if (revision === void 0 || signal.aborted) break;
10554
+ cursor = (cursor + 1) % items.length;
10555
+ let state = "unavailable";
10556
+ try {
10557
+ const assembly = await remote.assembly(item.workId, signal);
10558
+ if (assembly?.revision === revision) {
10559
+ if (assembly.error) state = "failed";
10560
+ else if (assembly.completedRevision !== revision || !assembly.scanId) state = "assembling";
10561
+ else state = await remote.assessment(assembly.scanId, signal);
10562
+ }
10563
+ } catch {
10564
+ state = "unavailable";
10565
+ }
10566
+ if (flight.signal.aborted || active !== flight) return;
10567
+ observations.set(item.workId, {
10568
+ workId: item.workId,
10569
+ revision,
10570
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
10571
+ state
10572
+ });
10573
+ }
10574
+ }
10575
+ async function read(address) {
10576
+ const flight = active;
10577
+ if (!flight || flight.signal.aborted) return [];
10578
+ const send = flight.current();
10579
+ if (send.journeyId !== address.journeyId || send.id !== address.sendId || send.subject !== flight.connection.subject || send.status !== "sending") return [];
10580
+ if (!reading && Date.now() >= next) {
10581
+ next = Date.now() + intervalMs;
10582
+ reading = refresh(flight).finally(() => {
10583
+ reading = void 0;
10584
+ });
10585
+ }
10586
+ await reading;
10587
+ if (active !== flight || flight.signal.aborted) return [];
10588
+ return accepted(flight.current()).flatMap((item) => {
10589
+ const value = observations.get(item.workId);
10590
+ return value && value.revision === item.sessionAssembly?.revision ? [value] : [];
10591
+ });
10592
+ }
10593
+ return {
10594
+ start,
10595
+ read
10596
+ };
9559
10597
  }
9560
10598
  //#endregion
9561
10599
  //#region apps/cli/src/account/journey/send.ts
@@ -9583,7 +10621,9 @@ function createJourneySender(dependencies, phases = 3, host = {
9583
10621
  const pendingElsewhere = (send) => !host.serving && send?.request !== void 0 && !send.confirmedAt;
9584
10622
  const progress = /* @__PURE__ */ new Map();
9585
10623
  let packagingRetry;
10624
+ const observer = createUploadObserver();
9586
10625
  const sessionFacts = createSessionFactsCache();
10626
+ const poll = createAssessmentPoll();
9587
10627
  const running = /* @__PURE__ */ new Map();
9588
10628
  const runKey = (address) => `${address.journeyId}:${address.sendId}`;
9589
10629
  const load = async (address) => {
@@ -9593,12 +10633,14 @@ function createJourneySender(dependencies, phases = 3, host = {
9593
10633
  return latest;
9594
10634
  };
9595
10635
  const sender = {
10636
+ uploadAssessments: observer.read,
9596
10637
  scopeValid(snapshot) {
9597
10638
  return !snapshot.send || (snapshot.send.request && !snapshot.send.confirmedAt ? requestMatches(snapshot.send, snapshot.journey) : scopeDigest(snapshot.journey) === snapshot.send.scopeDigest);
9598
10639
  },
9599
10640
  request: (input, source = "conversation") => requestSend(dependencies, input, hostId, source),
9600
10641
  async get(address) {
9601
- const snapshot = await dependencies.storage.loadSend(address.journeyId, address.sendId);
10642
+ const snapshot = await dependencies.storage.loadSend(address.journeyId, address.sendId, address.observedRevision);
10643
+ if (!snapshot && address.observedRevision !== void 0) return null;
9602
10644
  sessionFacts.retain(address.journeyId, snapshot?.send ?? null);
9603
10645
  if (host.serving && !address.sendId && snapshot?.send?.request && !snapshot.send.confirmedAt && snapshot.send.status !== "blocked") {
9604
10646
  const error = await requestError(dependencies, snapshot.send, hostId, new AbortController().signal);
@@ -9626,6 +10668,18 @@ function createJourneySender(dependencies, phases = 3, host = {
9626
10668
  }
9627
10669
  };
9628
10670
  },
10671
+ /** Link for the current native Send's authenticated owner, independent of Work assessment. */
10672
+ async ownedPassport(address, signal) {
10673
+ const { send } = await load(address);
10674
+ if (!send.confirmedAt || send.status !== "completed" || !send.subject || !send.items.length || !send.items.every((item) => item.sessionAssembly)) throw new ApplicationError("INVALID_TRANSITION");
10675
+ const connection = await dependencies.auth.connect(signal);
10676
+ if (!connection) throw new ApplicationError("ACCOUNT_SIGNED_OUT");
10677
+ if (connection.subject !== send.subject) throw new ApplicationError("IDENTITY_REJECTED");
10678
+ const url = await connection.ownedPassport(signal);
10679
+ await load(address);
10680
+ await connection.assertCurrent(signal);
10681
+ return { url };
10682
+ },
9629
10683
  /** The owner's current address, links and account email; null while signed out. */
9630
10684
  async ownerProfile(signal) {
9631
10685
  const connection = await dependencies.auth.connect(signal);
@@ -9656,8 +10710,12 @@ function createJourneySender(dependencies, phases = 3, host = {
9656
10710
  if (!connection) throw new ApplicationError("ACCOUNT_SIGNED_OUT");
9657
10711
  if (latest.send.subject !== connection.subject) throw new ApplicationError("IDENTITY_REJECTED");
9658
10712
  try {
9659
- const current = await connection.readProfile(signal);
9660
- await connection.saveProfile(mergeOwnerProfile(current.profile, chosen), signal);
10713
+ const remote = guardSendRemote(connection, {
10714
+ ...latest,
10715
+ item: null
10716
+ });
10717
+ const current = await remote.readProfile(signal);
10718
+ await remote.saveProfile(mergeOwnerProfile(current.profile, chosen), signal);
9661
10719
  } catch (error) {
9662
10720
  if (signal.aborted || error instanceof ApplicationError) throw error;
9663
10721
  const code = error instanceof JourneyRemoteError && error.status === 409 ? addressRefusals[error.code] : void 0;
@@ -9708,10 +10766,12 @@ function createJourneySender(dependencies, phases = 3, host = {
9708
10766
  try {
9709
10767
  const conflicts = inventory ? await sessionAccessBlocked(dependencies, input.journeyId, draft.items, inventory, signal) : [];
9710
10768
  for (const item of draft.items) {
9711
- const conflict = conflicts.find((entry) => entry.workId === item.workId && entry.accountAccess !== null) ?? conflicts.find((entry) => entry.workId === item.workId);
10769
+ const itemConflicts = conflicts.filter((entry) => entry.workId === item.workId);
10770
+ const conflict = itemConflicts.find((entry) => entry.accountAccess !== null) ?? itemConflicts[0];
9712
10771
  if (conflict) {
9713
10772
  const { workId: _, ...accessConflict } = conflict;
9714
10773
  item.accessConflict = accessConflict;
10774
+ item.accessConflictSessionCount = new Set(itemConflicts.map((entry) => entry.sessionKey)).size;
9715
10775
  }
9716
10776
  }
9717
10777
  } catch (error) {
@@ -9719,7 +10779,12 @@ function createJourneySender(dependencies, phases = 3, host = {
9719
10779
  }
9720
10780
  for (const item of inventory && recover && snapshot.send ? draft.items : []) {
9721
10781
  const current = inventory?.works.find((work) => work.logicalWorkId === item.workId);
9722
- const assembly = await remote.assembly(item.workId, signal);
10782
+ const earlier = snapshot.send?.items.find((value) => value.workId === item.workId);
10783
+ const assembly = await guardSendRemote(connection, {
10784
+ journey: snapshot.journey,
10785
+ send: snapshot.send,
10786
+ item: earlier ?? item
10787
+ }).sessions?.assembly(item.workId, signal);
9723
10788
  const selected = createSessionKeys();
9724
10789
  for await (const session of sessions(input.journeyId, item.artifact, signal)) await selected.add(session);
9725
10790
  const currentKeys = current?.sessions.map((session) => session.key) ?? [];
@@ -9735,8 +10800,11 @@ function createJourneySender(dependencies, phases = 3, host = {
9735
10800
  }
9736
10801
  }
9737
10802
  for (const item of observed ? [] : draft.items) {
9738
- const previous = snapshot.send?.items.find((entry) => entry.workId === item.workId && entry.access === item.access)?.accessConflict;
9739
- if (previous && !item.accessConflict) item.accessConflict = previous;
10803
+ const previous = snapshot.send?.items.find((entry) => entry.workId === item.workId && entry.access === item.access);
10804
+ if (previous?.accessConflict && !item.accessConflict) {
10805
+ item.accessConflict = previous.accessConflict;
10806
+ item.accessConflictSessionCount = previous.accessConflictSessionCount;
10807
+ }
9740
10808
  }
9741
10809
  const canReuseExistingSend = !(snapshot.send?.confirmedAt && ["blocked", "paused"].includes(snapshot.send.status)) && !recover && snapshot.send && scopeDigest(snapshot.journey) === snapshot.send.scopeDigest && snapshot.send.subject === draft.subject;
9742
10810
  const next = pending ? {
@@ -9792,7 +10860,10 @@ function createJourneySender(dependencies, phases = 3, host = {
9792
10860
  error: null
9793
10861
  };
9794
10862
  if (connection) try {
9795
- profile = await prepareProfile(dependencies, snapshot, connection, signal);
10863
+ profile = await prepareProfile(dependencies, snapshot, guardSendRemote(connection, {
10864
+ ...snapshot,
10865
+ item: null
10866
+ }), signal);
9796
10867
  } catch (error) {
9797
10868
  if (error instanceof ApplicationError && error.code === "REVISION_CONFLICT") return saveStart(snapshot.send);
9798
10869
  throw error;
@@ -9868,6 +10939,7 @@ function createJourneySender(dependencies, phases = 3, host = {
9868
10939
  const stop = new AbortController();
9869
10940
  const transferDependencies = {
9870
10941
  ...dependencies,
10942
+ ...host.serving ? { observeUpload: observer.start } : {},
9871
10943
  sessionTransfer: (workId, transfer) => {
9872
10944
  progress.set(`${key}:${workId}`, transfer);
9873
10945
  dependencies.sessionTransfer?.(workId, transfer);
@@ -9876,7 +10948,7 @@ function createJourneySender(dependencies, phases = 3, host = {
9876
10948
  const lifetime = AbortSignal.any([signal, stop.signal]);
9877
10949
  const result = (async () => {
9878
10950
  const currentSnapshot = await dependencies.storage.loadSend(address.journeyId);
9879
- if (!currentSnapshot?.send) return advanceWork(transferDependencies, address, lifetime, phases, sessionFacts);
10951
+ if (!currentSnapshot?.send) return advanceWork(transferDependencies, address, lifetime, phases, sessionFacts, poll);
9880
10952
  let snapshot = requireSend(currentSnapshot);
9881
10953
  if (snapshot.send.id !== address.sendId) return {
9882
10954
  ...snapshot,
@@ -9886,7 +10958,7 @@ function createJourneySender(dependencies, phases = 3, host = {
9886
10958
  ...snapshot,
9887
10959
  status: "applied"
9888
10960
  };
9889
- if (!host.serving && hostDrives(snapshot.send) && await host.focused?.(address.journeyId)) return {
10961
+ if (!host.serving && hostDrives(snapshot.journey, snapshot.send) && await host.focused?.(address.journeyId)) return {
9890
10962
  ...snapshot,
9891
10963
  status: "applied"
9892
10964
  };
@@ -9982,7 +11054,7 @@ function createJourneySender(dependencies, phases = 3, host = {
9982
11054
  confirmedOmittedWorkIds: snapshot.send.omitted.map((item) => item.workId)
9983
11055
  }, lifetime);
9984
11056
  }
9985
- return advanceWork(transferDependencies, address, lifetime, phases, sessionFacts);
11057
+ return advanceWork(transferDependencies, address, lifetime, phases, sessionFacts, poll);
9986
11058
  })().catch(async (error) => {
9987
11059
  if (!stop.signal.aborted || signal.aborted) throw error;
9988
11060
  return {
@@ -10079,6 +11151,7 @@ function createJourneySendAuth(account, requestFetch) {
10079
11151
  if (verified.identityId !== credential.identityId) throw new ApplicationError("IDENTITY_REJECTED");
10080
11152
  return createJourneyOwnerClient({
10081
11153
  apiUrl: account.configuration.passportApiUrl,
11154
+ ...account.configuration.passportWebOrigin ? { webOrigin: account.configuration.passportWebOrigin } : {},
10082
11155
  subject: verified.identityId,
10083
11156
  fetch: ownerFetch,
10084
11157
  async authorize() {
@@ -10093,6 +11166,27 @@ function createJourneySendAuth(account, requestFetch) {
10093
11166
  }
10094
11167
  //#endregion
10095
11168
  //#region apps/cli/src/account/journey/account-access.ts
11169
+ /**
11170
+ * Whole scope-confirm account read: identity, every inventory page and a token refresh. A 203-Work,
11171
+ * 1,400-session account took 2.3 to 3.4 s for its pages plus a refresh, past the earlier 5 s
11172
+ * (issue #53). On expiry the read is `unavailable` (ADR 0277 section 5).
11173
+ */
11174
+ const accountReadBoundMs = 3e4;
11175
+ const ownerBodyErrors = /* @__PURE__ */ new Set(["OWNER_EMPTY_RESPONSE", "OWNER_RESPONSE_LIMIT"]);
11176
+ /** Why the read failed, from error types and codes only; messages are never logged. */
11177
+ function failureClass(error, depth = 0) {
11178
+ if (!(error instanceof Error)) return "other";
11179
+ if (error.name === "TimeoutError") return "timeout";
11180
+ if (isCode(error, "ACCOUNT_SIGNED_OUT") || isCode(error, "IDENTITY_REJECTED")) return "signed-out";
11181
+ if (error instanceof JourneyRemoteError) {
11182
+ if (error.status === 401 || error.status === 403) return "signed-out";
11183
+ return error.status >= 500 || error.status === 429 ? "network" : "protocol";
11184
+ }
11185
+ if (isCode(error, "IDENTITY_UNAVAILABLE") && isInterrupted(error.cause)) return failureClass(error.cause, 1);
11186
+ if (isCode(error, "IDENTITY_UNAVAILABLE") || error instanceof SyntaxError || ownerBodyErrors.has(error.message)) return "protocol";
11187
+ if (isInterrupted(error)) return "network";
11188
+ return depth === 0 && error.cause !== void 0 ? failureClass(error.cause, 1) : "other";
11189
+ }
10096
11190
  /** Current and legacy upload keys of every part of one local session. */
10097
11191
  async function keysOf(session) {
10098
11192
  const keys = [];
@@ -10146,9 +11240,13 @@ async function accountAccessForRoots(state, roots, inventory, members) {
10146
11240
  }
10147
11241
  return result;
10148
11242
  }
10149
- /** Existing authenticated owner ports supply inventory; signed-out scope confirmation stays local. */
11243
+ /**
11244
+ * Existing authenticated owner ports supply inventory. The result is three-way (ADR 0277 section
11245
+ * 5): a caller stops on `signed-out` or `unavailable` instead of using model access.
11246
+ */
10150
11247
  function createAccountAccess(storage, auth) {
10151
11248
  return async (state, signal) => {
11249
+ const started = Date.now();
10152
11250
  const controller = new AbortController();
10153
11251
  const boundedSignal = AbortSignal.any([signal, controller.signal]);
10154
11252
  let timer;
@@ -10157,12 +11255,12 @@ function createAccountAccess(storage, auth) {
10157
11255
  onAbort = () => reject(/* @__PURE__ */ new Error("ACCOUNT_ACCESS_CANCELLED"));
10158
11256
  boundedSignal.addEventListener("abort", onAbort, { once: true });
10159
11257
  if (boundedSignal.aborted) onAbort();
10160
- timer = setTimeout(() => controller.abort(), 5e3);
11258
+ timer = setTimeout(() => controller.abort(), accountReadBoundMs);
10161
11259
  });
10162
11260
  const read = async () => {
10163
11261
  boundedSignal.throwIfAborted();
10164
11262
  const connection = await auth.connect(boundedSignal);
10165
- if (!connection?.sessions) return /* @__PURE__ */ new Map();
11263
+ if (!connection?.sessions) return { status: "signed-out" };
10166
11264
  const plan = (await storage.resources({
10167
11265
  journeyId: state.journeyId,
10168
11266
  kind: "plan"
@@ -10171,13 +11269,24 @@ function createAccountAccess(storage, auth) {
10171
11269
  const inventory = await connection.sessions.inventory(boundedSignal);
10172
11270
  const access = await accountAccessForRoots(state, plan.approval.inventory.sessions, inventory, reviewFamilies(plan.approval.inventory).members);
10173
11271
  await connection.assertCurrent(boundedSignal);
10174
- return access;
11272
+ return {
11273
+ status: "read",
11274
+ subject: connection.subject,
11275
+ access
11276
+ };
10175
11277
  };
10176
11278
  try {
10177
11279
  return await Promise.race([read(), stopped]);
10178
- } catch {
10179
- console.error("ACCOUNT_ACCESS_UNAVAILABLE: using model access");
10180
- return /* @__PURE__ */ new Map();
11280
+ } catch (error) {
11281
+ if (error instanceof ApplicationError && ![
11282
+ "ACCOUNT_SIGNED_OUT",
11283
+ "IDENTITY_REJECTED",
11284
+ "IDENTITY_UNAVAILABLE",
11285
+ "CREDENTIAL_STORE_FAILED"
11286
+ ].includes(error.code)) throw error;
11287
+ const reason = signal.aborted ? "cancelled" : controller.signal.aborted ? "timeout" : failureClass(error);
11288
+ console.error(`ACCOUNT_ACCESS_UNAVAILABLE: account read failed (class=${reason} elapsedMs=${Date.now() - started})`);
11289
+ return { status: reason === "signed-out" ? "signed-out" : "unavailable" };
10181
11290
  } finally {
10182
11291
  clearTimeout(timer);
10183
11292
  boundedSignal.removeEventListener("abort", onAbort);
@@ -10965,8 +12074,10 @@ function createPassportConnection(options) {
10965
12074
  //#region apps/cli/src/account/index.ts
10966
12075
  /** Composes the concrete Identity client, credential file and backend check for the account. */
10967
12076
  function createAccountPorts(options) {
12077
+ const webOrigin = passportWebOrigin(options.passportApiUrl, options.passportWebOrigin);
10968
12078
  return {
10969
12079
  configuration: {
12080
+ ...webOrigin ? { passportWebOrigin: webOrigin } : {},
10970
12081
  identityUrl: options.identityUrl,
10971
12082
  clientId: options.clientId,
10972
12083
  passportApiUrl: options.passportApiUrl
@@ -11313,7 +12424,8 @@ function createDiagnostics(stateDir, writeSummary, onFatal) {
11313
12424
  code: safeCode(error),
11314
12425
  at: (/* @__PURE__ */ new Date()).toISOString(),
11315
12426
  stage,
11316
- ...operationId ? { operationId } : {}
12427
+ ...operationId ? { operationId } : {},
12428
+ ...processingSite(error)
11317
12429
  };
11318
12430
  summary(diagnostic);
11319
12431
  if (closed) {
@@ -11349,14 +12461,18 @@ function createDiagnostics(stateDir, writeSummary, onFatal) {
11349
12461
  }
11350
12462
  },
11351
12463
  onDiagnostic(diagnostic, error) {
11352
- summary(diagnostic);
12464
+ const named = {
12465
+ ...diagnostic,
12466
+ ...processingSite(error)
12467
+ };
12468
+ summary(named);
11353
12469
  if (closed) {
11354
- unavailable(diagnostic);
12470
+ unavailable(named);
11355
12471
  return;
11356
12472
  }
11357
12473
  try {
11358
12474
  local.logger.error({
11359
- ...diagnostic,
12475
+ ...named,
11360
12476
  error: errorDetail(error)
11361
12477
  });
11362
12478
  } catch {
@@ -11815,6 +12931,7 @@ async function compose(options, callbacks, bundles) {
11815
12931
  identityUrl: options.identityUrl,
11816
12932
  clientId: options.identityClientId,
11817
12933
  passportApiUrl: options.passportApiUrl,
12934
+ ...options.passportWebOrigin ? { passportWebOrigin: options.passportWebOrigin } : {},
11818
12935
  onFatal: fatalStore.fail
11819
12936
  });
11820
12937
  const account = createAccount(accountPorts);
@@ -11872,6 +12989,13 @@ async function compose(options, callbacks, bundles) {
11872
12989
  }),
11873
12990
  journeyLocal: {
11874
12991
  accountAccess,
12992
+ async accountState() {
12993
+ const view = await account.status();
12994
+ return {
12995
+ configured: view.configuration.clientId !== null,
12996
+ subject: view.state === "signed-in" ? view.identityId : null
12997
+ };
12998
+ },
11875
12999
  titles: createJourneyTitles(journeyStorage, processing),
11876
13000
  describe: createJourneyDescription(journeyStorage, processing),
11877
13001
  works: createJourneyWorks(journeyStorage, processing, stateDir, accountAccess),
@@ -12104,7 +13228,7 @@ const delivery = z.object({
12104
13228
  output: z.string().min(1).max(4096)
12105
13229
  });
12106
13230
  /** `signal` carries the process's SIGINT/SIGTERM to long procedures such as journey.prepare. */
12107
- async function invoke(runtime, command, value, signal) {
13231
+ async function invoke(runtime, command, value, signal, getAudience = "agent") {
12108
13232
  const app = runtime.application;
12109
13233
  markAgentPresence(runtime.stateDir, value);
12110
13234
  switch (command) {
@@ -12115,15 +13239,37 @@ async function invoke(runtime, command, value, signal) {
12115
13239
  case "send.start": return app.journeySend?.start(sendStartSchema.parse(value));
12116
13240
  case "send.respond": return app.journeySend?.respond(sendChoiceSchema.parse(value));
12117
13241
  case "send.pause": return app.journeySend?.pause(sendActionSchema.parse(value));
12118
- case "send.resume": return app.journeySend?.resume(sendActionSchema.parse(value));
12119
- case "send.advance": return app.journeySend?.advance(sendAddress.parse(value));
12120
- case "journey.start": return app.journey?.start(value);
12121
- case "journey.get": return app.journey?.get(value ?? {}, "agent");
13242
+ case "send.resume": {
13243
+ const input = sendActionSchema.parse(value);
13244
+ const current = await runtime.store.journey?.current(input.journeyId);
13245
+ await fenceUnapprovedSendHost(runtime.stateDir, current);
13246
+ return app.journeySend?.resume(input);
13247
+ }
13248
+ case "send.advance": {
13249
+ const input = sendAddress.parse(value);
13250
+ const current = await runtime.store.journey?.current(input.journeyId);
13251
+ await fenceUnapprovedSendHost(runtime.stateDir, current);
13252
+ return app.journeySend?.advance(input);
13253
+ }
13254
+ case "journey.start": {
13255
+ const input = nativeStartSchema.parse(value);
13256
+ const current = await runtime.store.journey?.current();
13257
+ await fenceUnapprovedSendHost(runtime.stateDir, current);
13258
+ if (current && obsoleteJourney(current)) await relinquishObsoleteHost(runtime.stateDir, current.journeyId);
13259
+ return app.journey?.start(input);
13260
+ }
13261
+ case "journey.get": {
13262
+ const result = await app.journey?.get(value ?? {}, getAudience);
13263
+ await fenceUnapprovedSendHost(runtime.stateDir, result);
13264
+ if (result?.obsolete) await relinquishObsoleteHost(runtime.stateDir, result.journeyId);
13265
+ return result;
13266
+ }
12122
13267
  case "journey.groupItems": return app.journey?.groupItems(value);
12123
13268
  case "journey.inventory": return app.journey?.inventory(value);
12124
13269
  case "journey.classificationSessions": return app.journey?.classificationSessions(value);
12125
13270
  case "journey.classificationProgress": return app.journey?.classificationProgress(value);
12126
13271
  case "journey.classificationRead": return app.journey?.classificationRead(value);
13272
+ case "journey.recoverIsolatedMetadata": return app.journey?.recoverIsolatedMetadata(value, signal);
12127
13273
  case "journey.classificationSubmit": return app.journey?.classificationSubmit(value);
12128
13274
  case "journey.cardsRead": return app.journey?.cardsRead(value);
12129
13275
  case "journey.cardsSubmit": return app.journey?.cardsSubmit(value);
@@ -12143,7 +13289,14 @@ async function invoke(runtime, command, value, signal) {
12143
13289
  case "journey.findingImpact": return app.journey?.findingImpact(value);
12144
13290
  case "journey.preview": return app.journey?.preview(value);
12145
13291
  case "journey.files": return app.journey?.files(value);
12146
- case "journey.prepare": return app.journey?.prepare(value, signal);
13292
+ case "journey.prepare": {
13293
+ const input = z.object({
13294
+ journeyId: z.uuid(),
13295
+ result: z.literal("receipt").optional()
13296
+ }).strict().parse(value);
13297
+ return input.result === "receipt" ? app.journey?.prepareReceipt({ journeyId: input.journeyId }, signal) : app.journey?.prepare({ journeyId: input.journeyId }, signal);
13298
+ }
13299
+ case "review.reopenEmpty": return app.journey?.reopenEmpty(value);
12147
13300
  case "review.terms": return app.journey?.terms(value);
12148
13301
  case "review.status": return app.journey?.reviewStatus(value);
12149
13302
  case "review.guidance": return app.journey?.guidance(value);
@@ -12343,4 +13496,4 @@ async function cancelOperation(runtime, id) {
12343
13496
  return readOperation(runtime, id);
12344
13497
  }
12345
13498
  //#endregion
12346
- export { longCommands as _, readAgentPresence as a, traceStartup as b, executorRequestSchema as c, serveDetached as d, guardDetachedStartup as f, businessCommands as g, parseOptions as h, invoke as i, awaitingPassport as l, helpText as m, readOperation as n, createRuntime as o, createRenderActivity as p, waitOperation as r, executorEventSchema as s, cancelOperation as t, hostDrives as u, permissionDiagnostic as v, startupFailure as y };
13499
+ export { permissionDiagnostic as _, readAgentPresence as a, executorRequestSchema as c, packageIdentity as d, createRenderActivity as f, longCommands as g, businessCommands as h, invoke as i, serveDetached as l, parseOptions as m, readOperation as n, createRuntime as o, helpText as p, waitOperation as r, executorEventSchema as s, cancelOperation as t, guardDetachedStartup as u, startupFailure as v, traceStartup as y };