@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.
- package/README.md +47 -4
- package/bin/INDEX.md +6 -4
- package/bin/tacitt.cjs +1 -0
- package/dist/{agent-setup-LPd8WEsN.mjs → agent-setup-B12VbvN_.mjs} +2 -2
- package/dist/agent-setup-worker.mjs +5 -5
- package/dist/cli.mjs +39 -25
- package/dist/codex-title-worker.mjs +2 -2
- package/dist/configured-preview-worker.mjs +5 -5
- package/dist/configured-session-worker.mjs +27 -27
- package/dist/{frame-ByT1xayJ.mjs → frame-B3He21G8.mjs} +1 -1
- package/dist/{heap-CM298Yu0.mjs → heap-Blrat--4.mjs} +469 -30
- package/dist/inspection-worker.mjs +4 -4
- package/dist/{job-BHIwElNF.mjs → job-CBuIMTQ0.mjs} +57 -22
- package/dist/{locate-Cwv6WH8n.mjs → locate-D-ZaHpI0.mjs} +184 -18
- package/dist/{model-zvn5GUJ9.mjs → model-DR9odhWP.mjs} +1 -1
- package/dist/operation-executor.mjs +3 -3
- package/dist/preview-worker.mjs +1 -1
- package/dist/privacy-validator.mjs +49 -2
- package/dist/{query-By41gaKm.mjs → query-BYDcNKy2.mjs} +1869 -716
- package/dist/{run-ZQx7p4Rw.mjs → run-DQ9xyKSt.mjs} +4 -4
- package/dist/session-process.mjs +20 -0
- package/dist/{src-D_DfY--2.mjs → src-CKQlTA2Z.mjs} +297 -207
- package/dist/{src-3jvMvSQn.mjs → src-DeNJ8vOD.mjs} +535 -140
- package/dist/{src-BqtdFVq7.mjs → src-Do-qzuMn.mjs} +2329 -1786
- package/dist/store-database-worker.mjs +1 -1
- package/dist/web/assets/index-BgDdPL1c.js +172 -0
- package/dist/web/assets/index-DzOozqms.css +1 -0
- package/dist/web/index.html +3 -2
- package/package.json +2 -1
- package/skills/INDEX.md +6 -4
- package/skills/tacitt/INDEX.md +7 -6
- package/skills/tacitt/SKILL.md +11 -11
- package/skills/tacitt/references/INDEX.md +14 -11
- package/skills/tacitt/references/b-method.md +4 -4
- package/skills/tacitt/references/bootstrap.md +19 -5
- package/skills/tacitt/references/classification.md +4 -4
- package/skills/tacitt/references/commands.md +51 -34
- package/skills/tacitt/references/journey.md +7 -7
- package/skills/tacitt/references/parallel-review.md +35 -35
- package/skills/tacitt/references/send.md +15 -6
- package/skills/tacitt/scripts/INDEX.md +40 -37
- package/skills/tacitt/scripts/answer.mjs +91 -15
- package/skills/tacitt/scripts/journey-card-consolidation.mjs +1 -1
- package/skills/tacitt/scripts/journey-classification-metadata.mjs +75 -26
- package/skills/tacitt/scripts/journey-classification-progress.mjs +1 -2
- package/skills/tacitt/scripts/journey-classification-tasks.mjs +28 -8
- package/skills/tacitt/scripts/journey-classification.mjs +6 -1
- package/skills/tacitt/scripts/journey-consolidation.mjs +1 -0
- package/skills/tacitt/scripts/journey-panel-host.d.mts +2 -0
- package/skills/tacitt/scripts/journey-panel-host.mjs +25 -2
- package/skills/tacitt/scripts/journey-scan-timing.d.mts +5 -0
- package/skills/tacitt/scripts/journey-scan-timing.mjs +31 -4
- package/skills/tacitt/scripts/journey-state.d.mts +9 -1
- package/skills/tacitt/scripts/journey-state.mjs +95 -8
- package/skills/tacitt/scripts/journey-tasks.d.mts +1 -0
- package/skills/tacitt/scripts/journey-tasks.mjs +24 -9
- package/skills/tacitt/scripts/journey.d.mts +44 -31
- package/skills/tacitt/scripts/journey.mjs +34 -7
- package/skills/tacitt/scripts/privacy-validator.mjs +188 -11
- package/skills/tacitt/scripts/review.d.mts +7 -6
- package/skills/tacitt/scripts/review.mjs +922 -205
- package/dist/web/assets/index-B14e6_-z.js +0 -174
- package/dist/web/assets/index-CYJBq1px.css +0 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { $ as
|
|
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-
|
|
3
|
-
import { $t as
|
|
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-
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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`
|
|
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 [--
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
621
|
-
const file = await
|
|
639
|
+
const mode = part.representation === "jsonl" ? "append" : "snapshot";
|
|
640
|
+
const file = await openSource(part, mode);
|
|
622
641
|
try {
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
3437
|
-
|
|
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
|
|
4728
|
-
|
|
4729
|
-
|
|
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
|
|
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
|
|
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
|
-
...
|
|
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
|
|
5298
|
-
const batch = workIds.filter((id) => !ready(
|
|
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
|
|
5306
|
-
const
|
|
5307
|
-
|
|
5308
|
-
|
|
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
|
-
|
|
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(
|
|
5418
|
-
|
|
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
|
|
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
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
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 (
|
|
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
|
-
|
|
5640
|
-
|
|
5641
|
-
if (
|
|
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
|
-
...
|
|
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/
|
|
5729
|
-
|
|
5730
|
-
|
|
5731
|
-
|
|
5732
|
-
|
|
5733
|
-
|
|
5734
|
-
|
|
5735
|
-
|
|
5736
|
-
|
|
5737
|
-
|
|
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
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
async function
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
|
|
5788
|
-
|
|
5789
|
-
|
|
5790
|
-
|
|
5791
|
-
|
|
5792
|
-
|
|
5793
|
-
|
|
5794
|
-
|
|
5795
|
-
const
|
|
5796
|
-
|
|
5797
|
-
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
|
|
5801
|
-
|
|
5802
|
-
return
|
|
5803
|
-
|
|
5804
|
-
|
|
5805
|
-
|
|
5806
|
-
|
|
5807
|
-
|
|
5808
|
-
|
|
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
|
-
|
|
5846
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 ||
|
|
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
|
|
6217
|
-
const
|
|
6218
|
-
const legacyKey =
|
|
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
|
|
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/
|
|
6265
|
-
|
|
6266
|
-
|
|
6267
|
-
|
|
6268
|
-
|
|
6269
|
-
|
|
6270
|
-
|
|
6271
|
-
|
|
6272
|
-
|
|
6273
|
-
|
|
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
|
-
|
|
6276
|
-
|
|
6277
|
-
|
|
6278
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
6836
|
-
|
|
6837
|
-
|
|
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.
|
|
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
|
|
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 =
|
|
7928
|
+
const current = await remote.work(item.workId, signal);
|
|
7305
7929
|
if (current && current.access !== item.access) {
|
|
7306
|
-
|
|
7307
|
-
const [
|
|
7308
|
-
if (
|
|
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
|
-
|
|
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,
|
|
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
|
|
7577
|
-
|
|
7578
|
-
|
|
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
|
|
8503
|
+
const sessionFacts = chain.sessionFacts;
|
|
7703
8504
|
sessionFacts.retain(address.journeyId, snapshot.send);
|
|
7704
|
-
|
|
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
|
|
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
|
|
7719
|
-
|
|
7720
|
-
|
|
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:
|
|
8576
|
+
send: invalidated ? {
|
|
7757
8577
|
...snapshot.send,
|
|
7758
|
-
items: snapshot.send.items.filter((item) =>
|
|
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
|
-
|
|
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
|
-
|
|
7784
|
-
|
|
7785
|
-
|
|
7786
|
-
|
|
7787
|
-
|
|
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
|
-
|
|
7824
|
-
|
|
7825
|
-
|
|
7826
|
-
|
|
7827
|
-
|
|
7828
|
-
|
|
7829
|
-
|
|
7830
|
-
|
|
7831
|
-
|
|
7832
|
-
|
|
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
|
-
|
|
7839
|
-
|
|
7840
|
-
|
|
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
|
-
|
|
7843
|
-
|
|
8760
|
+
const { busyRefusals: _busy, ...finished } = result;
|
|
8761
|
+
return finished;
|
|
8762
|
+
}
|
|
7844
8763
|
} : {},
|
|
7845
|
-
connect: chain
|
|
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
|
-
|
|
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,
|
|
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)
|
|
9476
|
-
|
|
9477
|
-
|
|
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,
|
|
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
|
|
9660
|
-
|
|
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
|
|
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
|
|
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)
|
|
9739
|
-
if (previous && !item.accessConflict)
|
|
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,
|
|
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
|
-
/**
|
|
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(),
|
|
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
|
|
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
|
|
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
|
-
|
|
10180
|
-
|
|
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
|
-
|
|
12464
|
+
const named = {
|
|
12465
|
+
...diagnostic,
|
|
12466
|
+
...processingSite(error)
|
|
12467
|
+
};
|
|
12468
|
+
summary(named);
|
|
11353
12469
|
if (closed) {
|
|
11354
|
-
unavailable(
|
|
12470
|
+
unavailable(named);
|
|
11355
12471
|
return;
|
|
11356
12472
|
}
|
|
11357
12473
|
try {
|
|
11358
12474
|
local.logger.error({
|
|
11359
|
-
...
|
|
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":
|
|
12119
|
-
|
|
12120
|
-
|
|
12121
|
-
|
|
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":
|
|
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 {
|
|
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 };
|