chainlesschain 0.162.166 → 0.162.167
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/package.json +1 -1
- package/src/assets/web-panel/assets/{AIOps-LbDx7jL6.js → AIOps-BZJrchIA.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-DnurtK34.js → ActionButton-Bhlrylt7.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-DXSsV1am.js → Analytics-pHtXVQYs.js} +3 -3
- package/src/assets/web-panel/assets/{AppLayout-dqRpzNuD.js → AppLayout-ORpuri4R.js} +3 -3
- package/src/assets/web-panel/assets/{Artifacts-BwdmYepK.js → Artifacts-eC77yr1_.js} +1 -1
- package/src/assets/web-panel/assets/{Audit-oSX0kOUG.js → Audit-B2dZIiqz.js} +1 -1
- package/src/assets/web-panel/assets/{BackgroundAgents-C2KxJahT.js → BackgroundAgents-COZiTiwP.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-B4TkxWkC.js → Backup-vY_Srl5d.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-CX5-KxeU.js → BaseInput-Dili5BdM.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-O_HzD-PL.js → Chat-EzKBf0XL.js} +6 -6
- package/src/assets/web-panel/assets/{ChatBubbleRenderer-Br8saHQA.js → ChatBubbleRenderer-D1CtRAyy.js} +1 -1
- package/src/assets/web-panel/assets/{Checkbox-ChepDpC1.js → Checkbox-Beh6BmuW.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-D9z8kc7q.js → Codegen-C3fA6kBU.js} +1 -1
- package/src/assets/web-panel/assets/{Col-B-H5eoNw.js → Col-CefMqONH.js} +1 -1
- package/src/assets/web-panel/assets/{Community-Ccyp_Kk5.js → Community-CBAxFJtB.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-BNvBznp_.js → Compact--flHc-sM.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-D1FjRd8n.js → Compliance-CPeNLvA6.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-CV9B9u-8.js → Cowork-Bv5yv4BU.js} +2 -2
- package/src/assets/web-panel/assets/{Cron-BowJ4VIx.js → Cron-C22yMEas.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-BJc6jzRF.js → Crosschain-Dsb2bcpu.js} +1 -1
- package/src/assets/web-panel/assets/{DID-BEp8NV6D.js → DID-DAxei4w0.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-Dls0B3YB.js → Dashboard-DEUwuVZ8.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-DQ7zXVtD.js → Dropdown-BVp4si2j.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer-B9q79tOl.js → EmailListRenderer-CyFkybOG.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-CD8Ie-88.js → FamilyGuardDashboard-BWmmsbVx.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-5o7XU5Vf.js → Federation-Cxw27A5d.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-DeKbMvOs.js → FormItemContext-Q9XthzL1.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-CZiQvz_z.js → GenericCardRenderer-DS5DIdsW.js} +1 -1
- package/src/assets/web-panel/assets/{Git-CTvFxVV8.js → Git-BOliHfxP.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-DwzFIjHt.js → Governance-CD4oxa-W.js} +1 -1
- package/src/assets/web-panel/assets/{Inference-CJBk59c-.js → Inference-CMlcivo8.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-DfsaXN2z.js → KnowledgeGraph-BwhERcC4.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-DVRYf18S.js → Logs-BjVcZR5P.js} +2 -2
- package/src/assets/web-panel/assets/MarkdownRenderer-BfRhXdyG.js +1 -0
- package/src/assets/web-panel/assets/{Marketplace-BuIwyE4t.js → Marketplace-DQLU3E2w.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-D3U0o78E.js → McpTools-CeK0XkpB.js} +5 -5
- package/src/assets/web-panel/assets/{Memory-QYVmyb3b.js → Memory-DLFkgwaz.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-BFgrj6eZ.js → MobileBridge-Bh6ymXyj.js} +1 -1
- package/src/assets/web-panel/assets/{MobileProjects-Bp5QB-46.js → MobileProjects-D950tUIe.js} +1 -1
- package/src/assets/web-panel/assets/{Mtc-BZQu8TcH.js → Mtc-Bx5x9_2m.js} +4 -4
- package/src/assets/web-panel/assets/{MtcAudit-BnFfEkLo.js → MtcAudit-a1-Fexsq.js} +2 -2
- package/src/assets/web-panel/assets/{Multisig-iUnNS9Cm.js → Multisig-eaw0X8X1.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-DNNz1y9Y.js → NLProgramming-BaNsPV4k.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-CZezNYRU.js → Notes-C7pevN2X.js} +3 -3
- package/src/assets/web-panel/assets/{NotificationSettings-DBvrplIR.js → NotificationSettings-HDqeoP0r.js} +1 -1
- package/src/assets/web-panel/assets/{OrderTableRenderer-CTDtee49.js → OrderTableRenderer-DvzVl5T3.js} +1 -1
- package/src/assets/web-panel/assets/{Organization-CCOUJX1R.js → Organization-D_xHDlGW.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-DcZghdk0.js → Overflow-B5sRnueL.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-jmddWX9q.js → P2P-C-IRjOPW.js} +2 -2
- package/src/assets/web-panel/assets/{PdhVaultBrowser-wfjMcczl.js → PdhVaultBrowser-BqgUPlcU.js} +4 -4
- package/src/assets/web-panel/assets/{Permissions-DU1GJjca.js → Permissions-Bm_T1uWw.js} +4 -4
- package/src/assets/web-panel/assets/{PersonalDataHub-BzTMzDF9.js → PersonalDataHub-DujDllye.js} +4 -4
- package/src/assets/web-panel/assets/{Pipeline-DPmjlpVc.js → Pipeline-DyNPOLIG.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-B6HRLpfK.js → Privacy-CYKTW3iK.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-Cyy86AsN.js → ProjectInit-CGf03uZ0.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-Bl_WQxJ5.js → ProjectSettings-DIec4bTI.js} +2 -2
- package/src/assets/web-panel/assets/{Projects-Co9yKThA.js → Projects-BY43-bmi.js} +1 -1
- package/src/assets/web-panel/assets/{Providers-DLa4NlIs.js → Providers-RrnUmgPk.js} +1 -1
- package/src/assets/web-panel/assets/{QrScannerModal-BvzM7q5f.js → QrScannerModal-BBWfqd4y.js} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-BN8yyyUj.js → QuickAsk-BsICSrFp.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-QsVN4w0i.js → Recommend-B8y0b4ys.js} +1 -1
- package/src/assets/web-panel/assets/{RemoteSession-tkDXw-TY.js → RemoteSession-0QgdB1Oe.js} +2 -2
- package/src/assets/web-panel/assets/{Reputation-DAifkGsy.js → Reputation-DBJGM0YO.js} +1 -1
- package/src/assets/web-panel/assets/{Row-D7dZEyak.js → Row-DtWQ5otv.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-DB5sImTA.js → RssFeed-2zBxHd5H.js} +3 -3
- package/src/assets/web-panel/assets/{Search-3py3TO9V.js → Search-BofliSkZ.js} +1 -1
- package/src/assets/web-panel/assets/{Security-DhNUfT_k.js → Security-ockRBBEL.js} +3 -3
- package/src/assets/web-panel/assets/{Services-DjJAA_5D.js → Services-QyvG7Vd0.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-u95JjK3n.js → Skeleton-DwbOndX3.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-DPo3L0Tz.js → Skills-cHWa63sZ.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-DWhkAZV1.js → Sla-BHnTEydQ.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-PUegMtqV.js → SpeechSettings-DE2hrrLb.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-BMNYrnrU.js → SyncSettings-CZ9vnbJH.js} +2 -2
- package/src/assets/web-panel/assets/Tasks-DWgoAHvM.js +1 -0
- package/src/assets/web-panel/assets/{Templates-Dw9Er5DN.js → Templates-DiR3ad4X.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-7P3QIBSo.js → Tenant-NqcW3frV.js} +1 -1
- package/src/assets/web-panel/assets/{Terminal-ClPrfWW7.js → Terminal-iYeqRQZ0.js} +2 -2
- package/src/assets/web-panel/assets/{TimelineRenderer-DbCd1bDH.js → TimelineRenderer-C5GOoxFz.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-EQofpA0t.js → Tokens-82dz0XdU.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-ChNLLypd.js → Trigger-B3JCNrOi.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-dlVGUKs5.js → Trust-BehFYsN_.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-BXyBUhNF.js → UkeySign-BfOxJjVf.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-BE8-68ge.js → VideoEditing-DdITCCPz.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-7ZIJOwNg.js → Wallet-B0EhkCmD.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-DgNd2Amr.js → WebAuthn-DJ4i1f3-.js} +3 -3
- package/src/assets/web-panel/assets/{WorkflowEditor-Z-Un-9l5.js → WorkflowEditor-Dr9XTqIT.js} +1 -1
- package/src/assets/web-panel/assets/{chat-CtVDEkUc.js → chat-Dc2nFyOj.js} +1 -1
- package/src/assets/web-panel/assets/{colors-PXtJ2f1B.js → colors-Drw9djBR.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-oN96nuBi.js → compact-item-BdLXbU52.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-okCJXQXa.js → createContext-BpQMZiWY.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-CnfpDzMr.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-pcHm--4o.js → hasIn-CbYwawHf.js} +1 -1
- package/src/assets/web-panel/assets/{index-BZKk4Lnm.js → index-B1ANzVc6.js} +1 -1
- package/src/assets/web-panel/assets/{index-CQYes2mk.js → index-B3uRI8_y.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cc_MkksW.js → index-B5KboakZ.js} +1 -1
- package/src/assets/web-panel/assets/{index-CQ1ZzJjN.js → index-B9_u4Qhe.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bht9n8Sm.js → index-BAq-4KqY.js} +1 -1
- package/src/assets/web-panel/assets/{index-_SaFHNsG.js → index-BBueKSTR.js} +1 -1
- package/src/assets/web-panel/assets/{index-jAY55MSx.js → index-BIcEW07n.js} +1 -1
- package/src/assets/web-panel/assets/{index-nMqLdwao.js → index-BWUoc9-s.js} +1 -1
- package/src/assets/web-panel/assets/{index-BHJIfdCV.js → index-BZxzdAI0.js} +1 -1
- package/src/assets/web-panel/assets/{index-CAQktO-6.js → index-BiGDrEt4.js} +1 -1
- package/src/assets/web-panel/assets/{index-BjVIZfdA.js → index-Blv7c80V.js} +1 -1
- package/src/assets/web-panel/assets/{index-BEtzqJw3.js → index-BmQlot_u.js} +1 -1
- package/src/assets/web-panel/assets/{index-D0bfypsF.js → index-Bo_KwJTF.js} +1 -1
- package/src/assets/web-panel/assets/{index-C0rfJBU3.js → index-Bu8Vy-mj.js} +1 -1
- package/src/assets/web-panel/assets/{index-BuME9k4Y.js → index-C1eo1Qkd.js} +1 -1
- package/src/assets/web-panel/assets/{index-D1efSh1X.js → index-C3Alz2QD.js} +1 -1
- package/src/assets/web-panel/assets/{index-DBcKBb1E.js → index-CBwQwtsE.js} +1 -1
- package/src/assets/web-panel/assets/{index-C4ravjdv.js → index-CJQiSQw7.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cs1fGk1Z.js → index-CKmz3Is-.js} +1 -1
- package/src/assets/web-panel/assets/{index-BUUJ2WfG.js → index-COoIIMbO.js} +1 -1
- package/src/assets/web-panel/assets/index-CYTEB0tb.js +1 -0
- package/src/assets/web-panel/assets/index-CgsEp5bm.js +1 -0
- package/src/assets/web-panel/assets/{index-DC0bqrqV.js → index-CpmlTfpv.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bl6N7ZyX.js → index-D7Ipfs4F.js} +1 -1
- package/src/assets/web-panel/assets/{index-vW0lwj0Y.js → index-DHMeEu-P.js} +1 -1
- package/src/assets/web-panel/assets/{index-mB1UV7wA.js → index-DKQlF7Pw.js} +1 -1
- package/src/assets/web-panel/assets/{index-BDR2mi1p.js → index-DO7LD_1t.js} +1 -1
- package/src/assets/web-panel/assets/{index-CK0H3pQW.js → index-DSmZjrFy.js} +1 -1
- package/src/assets/web-panel/assets/{index-DtsyPnF5.js → index-DTeqWdAS.js} +1 -1
- package/src/assets/web-panel/assets/{index-T3QE8LuV.js → index-DWUN8c01.js} +1 -1
- package/src/assets/web-panel/assets/{index-BUTyx-Lk.js → index-D_lkTnZ0.js} +1 -1
- package/src/assets/web-panel/assets/{index-5aO2V0V4.js → index-DyU21xVi.js} +1 -1
- package/src/assets/web-panel/assets/{index-CWUJlCvK.js → index-GG_MXjSL.js} +3 -3
- package/src/assets/web-panel/assets/{index-CopGaCI_.js → index-IG3__pGj.js} +1 -1
- package/src/assets/web-panel/assets/{index-BnhHxZul.js → index-UX_Mfyfv.js} +1 -1
- package/src/assets/web-panel/assets/{index-CXhezK4d.js → index-VIyNlXPX.js} +1 -1
- package/src/assets/web-panel/assets/{index-Rk0X8lVz.js → index-XYe6kBmi.js} +1 -1
- package/src/assets/web-panel/assets/{index-DfL10EcN.js → index-_vVucYqC.js} +1 -1
- package/src/assets/web-panel/assets/{index-BeAWCGEK.js → index-aHrUfrv1.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-CG1jyiI7.js → initDefaultProps-DybC85E4.js} +1 -1
- package/src/assets/web-panel/assets/{motion-C8BYa98D.js → motion-BNNuyDg7.js} +1 -1
- package/src/assets/web-panel/assets/{move-D1hqwitW.js → move-Bm8gSjEN.js} +1 -1
- package/src/assets/web-panel/assets/{mtc-parser-C2lol8rp.js → mtc-parser-CuHQvzzr.js} +1 -1
- package/src/assets/web-panel/assets/{omit-Ct6Zghfi.js → omit-lv4NO7Ng.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-CSYbYfdc.js → pickAttrs-BoSMfjHq.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-WCk8di0z.js → placementArrow-BOMDSvgz.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-CEC8CZ-3.js → responsiveObserve-C064Ubmu.js} +1 -1
- package/src/assets/web-panel/assets/{slide-BsXyd7Lq.js → slide-CeqhbKoG.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-tDKldiUT.js → statusUtils-CunFoYww.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-BIS2unhx.js → styleChecker-Cxz3wPL7.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-yFTVMDUv.js → useFlexGapSupport-DJsIaU2S.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-CZYbJwkO.js → useFs-DnAygceJ.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-sbEHRLAM.js → usePersonalDataHub-CrEUY7ta.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-BVjeSEQK.js → vnode-Cq3rN2eT.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-BZKVvEZ6.js → zoom-TMzcOx__.js} +1 -1
- package/src/assets/web-panel/index.html +1 -1
- package/src/commands/agent.js +75 -0
- package/src/commands/session.js +132 -0
- package/src/data/changelog.json +7 -2
- package/src/harness/jsonl-session-store.js +9 -0
- package/src/harness/session-mirror.js +212 -6
- package/src/lib/async-hook-queue.cjs +288 -0
- package/src/lib/async-hook-supervisor.cjs +52 -0
- package/src/lib/doctor-checkup.js +184 -4
- package/src/lib/install-command-policy.js +333 -0
- package/src/lib/jsonl-session-store.js +1 -0
- package/src/lib/lsp/__tests__/lsp-server-registry.test.js +27 -0
- package/src/lib/lsp/lsp-server-registry.js +14 -0
- package/src/lib/runtime-checkup.js +33 -0
- package/src/lib/settings-hook-events.cjs +157 -0
- package/src/lib/settings-hooks.cjs +4 -0
- package/src/lib/shell-approval.js +47 -1
- package/src/repl/add-dir.js +59 -1
- package/src/repl/agent-repl.js +85 -3
- package/src/runtime/headless-runner.js +69 -1
- package/src/runtime/headless-stream.js +22 -0
- package/src/runtime/system-prompt.js +39 -12
- package/src/assets/web-panel/assets/MarkdownRenderer-BL_WsWZM.js +0 -1
- package/src/assets/web-panel/assets/Tasks-CYm_oTJC.js +0 -1
- package/src/assets/web-panel/assets/devWarning-BzV6dk1B.js +0 -1
- package/src/assets/web-panel/assets/index-BFrAwXHp.js +0 -1
- package/src/assets/web-panel/assets/index-DJQFP47u.js +0 -1
|
@@ -26,10 +26,20 @@ import {
|
|
|
26
26
|
readFileSync,
|
|
27
27
|
writeFileSync,
|
|
28
28
|
readdirSync,
|
|
29
|
+
unlinkSync,
|
|
29
30
|
} from "node:fs";
|
|
31
|
+
import {
|
|
32
|
+
scryptSync,
|
|
33
|
+
randomBytes,
|
|
34
|
+
createCipheriv,
|
|
35
|
+
createDecipheriv,
|
|
36
|
+
} from "node:crypto";
|
|
30
37
|
import { join, basename } from "node:path";
|
|
31
38
|
import { isUnsafeSessionId } from "./jsonl-session-store.js";
|
|
32
39
|
|
|
40
|
+
const CIPHER_VERSION = 1;
|
|
41
|
+
const CIPHER_ALGO = "aes-256-gcm";
|
|
42
|
+
|
|
33
43
|
/**
|
|
34
44
|
* Build a mirror driver from config. `config.kind` selects the driver:
|
|
35
45
|
* { kind: "fs", dir: "/mnt/backup/sessions" }
|
|
@@ -39,15 +49,193 @@ import { isUnsafeSessionId } from "./jsonl-session-store.js";
|
|
|
39
49
|
export function createMirror(config = {}, deps = {}) {
|
|
40
50
|
const kind = String(config.kind || config.type || "none").toLowerCase();
|
|
41
51
|
if (!kind || kind === "none") return null;
|
|
52
|
+
let driver;
|
|
42
53
|
if (kind === "fs" || kind === "dir" || kind === "directory") {
|
|
43
|
-
|
|
54
|
+
driver = createFsMirror(config, deps);
|
|
55
|
+
} else if (kind === "http" || kind === "api") {
|
|
56
|
+
driver = createHttpMirror(config, deps);
|
|
57
|
+
} else {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`unknown session mirror kind "${kind}" (use fs | http, or wire an s3/webdav/redis driver)`,
|
|
60
|
+
);
|
|
44
61
|
}
|
|
45
|
-
|
|
46
|
-
|
|
62
|
+
// When `encryption` (a passphrase) is configured, wrap the driver so bytes are
|
|
63
|
+
// AES-256-GCM encrypted at rest — the mirror target only ever holds ciphertext,
|
|
64
|
+
// and only the run-time passphrase can decrypt it. Absent → raw bytes (the
|
|
65
|
+
// prior behaviour, byte-for-byte).
|
|
66
|
+
const enc = config.encryption || config.encrypt;
|
|
67
|
+
if (enc && (enc.passphrase || enc.secret)) {
|
|
68
|
+
return wrapEncryptedMirror(driver, createMirrorCipher(enc, deps));
|
|
47
69
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
70
|
+
return driver;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Passphrase-based AES-256-GCM cipher for mirrored session bytes. scrypt derives
|
|
75
|
+
* a 256-bit key from the passphrase + a per-record random salt; a random 12-byte
|
|
76
|
+
* nonce and the GCM tag give confidentiality + integrity (a tampered ciphertext
|
|
77
|
+
* fails to decrypt). Each envelope is self-describing JSON — `{ v, alg, keyId,
|
|
78
|
+
* salt, nonce, tag, ct }`, all base64url — carrying `keyId` so key ROTATION can
|
|
79
|
+
* tell old ciphertext from new. No secret is ever stored; only the passphrase
|
|
80
|
+
* supplied at run time can read it. `randomBytes`/`scryptSync` are injectable so
|
|
81
|
+
* round-trips are unit-testable deterministically.
|
|
82
|
+
*/
|
|
83
|
+
export function createMirrorCipher(config = {}, deps = {}) {
|
|
84
|
+
const passphrase = config.passphrase || config.secret;
|
|
85
|
+
if (!passphrase) throw new Error("mirror encryption requires a `passphrase`");
|
|
86
|
+
const keyId = String(config.keyId || "default");
|
|
87
|
+
const _randomBytes = deps.randomBytes || randomBytes;
|
|
88
|
+
const _scrypt = deps.scryptSync || scryptSync;
|
|
89
|
+
const deriveKey = (salt) => _scrypt(String(passphrase), salt, 32);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
keyId,
|
|
93
|
+
encrypt(bytes) {
|
|
94
|
+
const salt = Buffer.from(_randomBytes(16));
|
|
95
|
+
const nonce = Buffer.from(_randomBytes(12));
|
|
96
|
+
const cipher = createCipheriv(CIPHER_ALGO, deriveKey(salt), nonce);
|
|
97
|
+
const ct = Buffer.concat([
|
|
98
|
+
cipher.update(Buffer.from(bytes, "utf-8")),
|
|
99
|
+
cipher.final(),
|
|
100
|
+
]);
|
|
101
|
+
return JSON.stringify({
|
|
102
|
+
v: CIPHER_VERSION,
|
|
103
|
+
alg: CIPHER_ALGO,
|
|
104
|
+
keyId,
|
|
105
|
+
salt: salt.toString("base64url"),
|
|
106
|
+
nonce: nonce.toString("base64url"),
|
|
107
|
+
tag: cipher.getAuthTag().toString("base64url"),
|
|
108
|
+
ct: ct.toString("base64url"),
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
decrypt(envelope) {
|
|
112
|
+
let e;
|
|
113
|
+
try {
|
|
114
|
+
e = typeof envelope === "string" ? JSON.parse(envelope) : envelope;
|
|
115
|
+
} catch {
|
|
116
|
+
throw new Error("mirror ciphertext is not a valid envelope");
|
|
117
|
+
}
|
|
118
|
+
if (!e || e.v !== CIPHER_VERSION || e.alg !== CIPHER_ALGO) {
|
|
119
|
+
throw new Error("unsupported mirror ciphertext envelope");
|
|
120
|
+
}
|
|
121
|
+
const key = deriveKey(Buffer.from(e.salt, "base64url"));
|
|
122
|
+
const decipher = createDecipheriv(
|
|
123
|
+
CIPHER_ALGO,
|
|
124
|
+
key,
|
|
125
|
+
Buffer.from(e.nonce, "base64url"),
|
|
126
|
+
);
|
|
127
|
+
decipher.setAuthTag(Buffer.from(e.tag, "base64url"));
|
|
128
|
+
const pt = Buffer.concat([
|
|
129
|
+
decipher.update(Buffer.from(e.ct, "base64url")),
|
|
130
|
+
decipher.final(),
|
|
131
|
+
]);
|
|
132
|
+
return pt.toString("utf-8");
|
|
133
|
+
},
|
|
134
|
+
/** Read an envelope's keyId without decrypting (rotation bookkeeping). */
|
|
135
|
+
envelopeKeyId(envelope) {
|
|
136
|
+
try {
|
|
137
|
+
const e =
|
|
138
|
+
typeof envelope === "string" ? JSON.parse(envelope) : envelope;
|
|
139
|
+
return e && e.keyId != null ? String(e.keyId) : null;
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Wrap a driver so push encrypts and pull decrypts transparently, keeping the
|
|
149
|
+
* raw driver reachable as `.raw` (key rotation drives the raw driver with two
|
|
150
|
+
* ciphers). list/delete pass through — ids stay plaintext in the target (they
|
|
151
|
+
* are random handles, not content).
|
|
152
|
+
*/
|
|
153
|
+
function wrapEncryptedMirror(driver, cipher) {
|
|
154
|
+
return {
|
|
155
|
+
...driver,
|
|
156
|
+
encrypted: true,
|
|
157
|
+
keyId: cipher.keyId,
|
|
158
|
+
raw: driver,
|
|
159
|
+
cipher,
|
|
160
|
+
async push(id, bytes) {
|
|
161
|
+
const enc = cipher.encrypt(bytes);
|
|
162
|
+
const r = await driver.push(id, enc);
|
|
163
|
+
return {
|
|
164
|
+
...r,
|
|
165
|
+
bytes: Buffer.byteLength(bytes),
|
|
166
|
+
storedBytes: Buffer.byteLength(enc),
|
|
167
|
+
encrypted: true,
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
async pull(id) {
|
|
171
|
+
const raw = await driver.pull(id);
|
|
172
|
+
return raw == null ? null : cipher.decrypt(raw);
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* RETENTION: delete every mirror entry whose id is NOT in `keep` — the mirror is
|
|
179
|
+
* one-way derived from the local store, so retention = "hold only what the local
|
|
180
|
+
* source still keeps" (e.g. the ids `cc session list` still returns). Best-effort
|
|
181
|
+
* per id; returns what was deleted vs kept.
|
|
182
|
+
*/
|
|
183
|
+
export async function pruneMirror(mirror, { keep = [] } = {}) {
|
|
184
|
+
if (!mirror || typeof mirror.list !== "function") {
|
|
185
|
+
return { deleted: [], kept: [] };
|
|
186
|
+
}
|
|
187
|
+
const keepSet = new Set((keep || []).map(String));
|
|
188
|
+
const ids = (await mirror.list()) || [];
|
|
189
|
+
const deleted = [];
|
|
190
|
+
for (const id of ids) {
|
|
191
|
+
if (keepSet.has(String(id))) continue;
|
|
192
|
+
try {
|
|
193
|
+
await mirror.delete(id);
|
|
194
|
+
deleted.push(id);
|
|
195
|
+
} catch {
|
|
196
|
+
/* best-effort — a failed delete must not abort the prune */
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { deleted, kept: ids.filter((i) => keepSet.has(String(i))) };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* KEY ROTATION: re-encrypt every mirrored session from the `from` cipher to the
|
|
204
|
+
* `to` cipher, in place. Drives the RAW driver (pass `mirror.raw` for an
|
|
205
|
+
* encrypted mirror) so it reads/writes ciphertext directly. Entries already at
|
|
206
|
+
* `to.keyId` are skipped (idempotent re-runs); an entry that fails to decrypt
|
|
207
|
+
* with `from` is reported, not silently dropped.
|
|
208
|
+
*/
|
|
209
|
+
export async function rotateMirrorKey(driver, { from, to } = {}) {
|
|
210
|
+
if (!driver || !from || !to) {
|
|
211
|
+
throw new Error("rotateMirrorKey requires (driver, { from, to })");
|
|
212
|
+
}
|
|
213
|
+
const ids = (await driver.list()) || [];
|
|
214
|
+
const rotated = [];
|
|
215
|
+
const skipped = [];
|
|
216
|
+
const failed = [];
|
|
217
|
+
for (const id of ids) {
|
|
218
|
+
let raw;
|
|
219
|
+
try {
|
|
220
|
+
raw = await driver.pull(id);
|
|
221
|
+
} catch (e) {
|
|
222
|
+
failed.push({ id, error: e.message });
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (raw == null) continue;
|
|
226
|
+
if (from.envelopeKeyId(raw) === to.keyId) {
|
|
227
|
+
skipped.push(id);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
const plain = from.decrypt(raw);
|
|
232
|
+
await driver.push(id, to.encrypt(plain));
|
|
233
|
+
rotated.push(id);
|
|
234
|
+
} catch (e) {
|
|
235
|
+
failed.push({ id, error: e.message });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return { rotated, skipped, failed, keyId: to.keyId };
|
|
51
239
|
}
|
|
52
240
|
|
|
53
241
|
/** Directory mirror — the reference driver. */
|
|
@@ -59,6 +247,7 @@ export function createFsMirror(config = {}, deps = {}) {
|
|
|
59
247
|
const _readFileSync = deps.readFileSync || readFileSync;
|
|
60
248
|
const _writeFileSync = deps.writeFileSync || writeFileSync;
|
|
61
249
|
const _readdirSync = deps.readdirSync || readdirSync;
|
|
250
|
+
const _unlinkSync = deps.unlinkSync || unlinkSync;
|
|
62
251
|
|
|
63
252
|
const ensure = () => {
|
|
64
253
|
if (!_existsSync(dir)) _mkdirSync(dir, { recursive: true });
|
|
@@ -88,6 +277,12 @@ export function createFsMirror(config = {}, deps = {}) {
|
|
|
88
277
|
.filter((f) => f.endsWith(".jsonl"))
|
|
89
278
|
.map((f) => basename(f, ".jsonl"));
|
|
90
279
|
},
|
|
280
|
+
async delete(id) {
|
|
281
|
+
const f = fileFor(id);
|
|
282
|
+
if (!_existsSync(f)) return { id, deleted: false };
|
|
283
|
+
_unlinkSync(f);
|
|
284
|
+
return { id, deleted: true };
|
|
285
|
+
},
|
|
91
286
|
};
|
|
92
287
|
}
|
|
93
288
|
|
|
@@ -138,5 +333,16 @@ export function createHttpMirror(config = {}, deps = {}) {
|
|
|
138
333
|
.map((x) => (typeof x === "string" ? x : x?.id))
|
|
139
334
|
.filter(Boolean);
|
|
140
335
|
},
|
|
336
|
+
async delete(id) {
|
|
337
|
+
const res = await doFetch(`${baseUrl}/sessions/${guard(id)}`, {
|
|
338
|
+
method: "DELETE",
|
|
339
|
+
headers: headers(),
|
|
340
|
+
});
|
|
341
|
+
// 404 = already gone → idempotent success, not an error.
|
|
342
|
+
if (!res.ok && res.status !== 404) {
|
|
343
|
+
throw new Error(`mirror DELETE ${id} → HTTP ${res.status}`);
|
|
344
|
+
}
|
|
345
|
+
return { id, deleted: res.ok };
|
|
346
|
+
},
|
|
141
347
|
};
|
|
142
348
|
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* async-hook-queue — a small, best-effort, PERSISTENT queue for async settings
|
|
5
|
+
* hooks that opted into `asyncRewake: true` and finished in a FAILURE state.
|
|
6
|
+
*
|
|
7
|
+
* Why: AsyncHookSupervisor runs `async: true` hooks fire-and-forget and keeps
|
|
8
|
+
* their results ONLY in memory (`_results`/`_rewakes`). A rewake is the one
|
|
9
|
+
* actionable signal it produces — "a background check (e.g. `npm test`) failed,
|
|
10
|
+
* re-engage the agent with the error". If the process dies (crash, kill,
|
|
11
|
+
* hard exit) after the rewake was recorded but BEFORE the turn loop drained and
|
|
12
|
+
* surfaced it, that signal is silently lost: the next `--resume` of the same
|
|
13
|
+
* session has no idea a background check failed.
|
|
14
|
+
*
|
|
15
|
+
* This module gives the supervisor a durable place to park undrained rewakes,
|
|
16
|
+
* keyed by session id, so `--resume <id>` can recover them. The gap's P2 "Hooks"
|
|
17
|
+
* item calls for an "async hook 持久队列"; this is the reliability half of it
|
|
18
|
+
* (paired with the already-shipped `allowManagedHooksOnly`).
|
|
19
|
+
*
|
|
20
|
+
* Lifecycle (rewake-scoped on purpose — non-rewake results are informational and
|
|
21
|
+
* cheap to lose; rewakes are the failure signal worth surviving a crash):
|
|
22
|
+
* record a rewake → appendRewake() (immediate write, survives even SIGKILL)
|
|
23
|
+
* drain / surface → removeRewakes() (consumed → no longer pending)
|
|
24
|
+
* resume a session → takePending() (load + clear that session's bucket)
|
|
25
|
+
*
|
|
26
|
+
* Design mirrors hook-stats-store.cjs: pure folds (no clock/RNG/fs inside), an
|
|
27
|
+
* injected fs for load/save, atomic tmp+rename writes, and per-session +
|
|
28
|
+
* per-session-count bounds so a runaway producer never grows the file without
|
|
29
|
+
* bound. Every path is best-effort — a corrupt/io-failed queue file must never
|
|
30
|
+
* block a hook or the agent.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const os = require("node:os");
|
|
34
|
+
const path = require("node:path");
|
|
35
|
+
const fsDefault = require("node:fs");
|
|
36
|
+
|
|
37
|
+
const CONFIG_DIR_NAME = ".chainlesschain";
|
|
38
|
+
const MAX_RECORDS_PER_SESSION = 200; // drop oldest-recorded beyond this
|
|
39
|
+
const MAX_SESSIONS = 100; // bound distinct session buckets
|
|
40
|
+
const DEFAULT_STALE_MS = 7 * 24 * 60 * 60 * 1000; // prune buckets untouched >7d
|
|
41
|
+
|
|
42
|
+
/** Default on-disk location (matches the ESM getHomeDir() layout). */
|
|
43
|
+
function defaultAsyncHookQueuePath() {
|
|
44
|
+
return path.join(os.homedir(), CONFIG_DIR_NAME, "async-hook-queue.json");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Stable de-dupe key for one rewake record. Two records with the same
|
|
49
|
+
* (command, event, ts, ms, exitCode) are treated as the same pending item —
|
|
50
|
+
* enough to make removeRewakes() idempotent and appendRewake() not double-store
|
|
51
|
+
* a record that a caller replays. Pure, no hashing needed.
|
|
52
|
+
*/
|
|
53
|
+
function queueEntryKey(rec) {
|
|
54
|
+
if (!rec || typeof rec !== "object") return "";
|
|
55
|
+
return [
|
|
56
|
+
String(rec.command || ""),
|
|
57
|
+
String(rec.event || ""),
|
|
58
|
+
String(rec.ts ?? ""),
|
|
59
|
+
String(rec.ms ?? ""),
|
|
60
|
+
String(rec.exitCode ?? ""),
|
|
61
|
+
].join("|");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Normalize a raw supervisor record into a compact stored rewake entry. */
|
|
65
|
+
function normalizeRecord(rec) {
|
|
66
|
+
if (!rec || typeof rec !== "object") return null;
|
|
67
|
+
const command = String(rec.command || "").trim();
|
|
68
|
+
if (!command) return null;
|
|
69
|
+
return {
|
|
70
|
+
command,
|
|
71
|
+
event: rec.event ?? null,
|
|
72
|
+
exitCode: rec.exitCode ?? null,
|
|
73
|
+
error: rec.error ? String(rec.error) : null,
|
|
74
|
+
additionalContext: rec.additionalContext
|
|
75
|
+
? String(rec.additionalContext)
|
|
76
|
+
: null,
|
|
77
|
+
blocked: !!rec.blocked,
|
|
78
|
+
ts: Number.isFinite(Number(rec.ts)) ? Number(rec.ts) : 0,
|
|
79
|
+
ms: Number.isFinite(Number(rec.ms)) ? Number(rec.ms) : 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Fresh empty queue. */
|
|
84
|
+
function emptyQueue() {
|
|
85
|
+
return { sessions: {} };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Coerce an arbitrary parsed value into the queue shape. */
|
|
89
|
+
function coerceQueue(parsed) {
|
|
90
|
+
if (
|
|
91
|
+
!parsed ||
|
|
92
|
+
typeof parsed !== "object" ||
|
|
93
|
+
typeof parsed.sessions !== "object" ||
|
|
94
|
+
!parsed.sessions
|
|
95
|
+
) {
|
|
96
|
+
return emptyQueue();
|
|
97
|
+
}
|
|
98
|
+
return { sessions: parsed.sessions };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* PURE fold: append `records` into `queue` under `sessionId` (mutates + returns
|
|
103
|
+
* `queue`). De-dupes by queueEntryKey, keeps the most-recent MAX_RECORDS_PER_
|
|
104
|
+
* SESSION, and stamps the bucket's `updatedAt` for stale pruning. `now` is
|
|
105
|
+
* supplied by the caller so the fold stays clock-free.
|
|
106
|
+
*/
|
|
107
|
+
function foldAppend(queue, sessionId, records, now) {
|
|
108
|
+
if (!queue || typeof queue !== "object") queue = emptyQueue();
|
|
109
|
+
if (!queue.sessions) queue.sessions = {};
|
|
110
|
+
const id = String(sessionId || "").trim();
|
|
111
|
+
if (!id) return queue;
|
|
112
|
+
const list = Array.isArray(records) ? records : [records];
|
|
113
|
+
const bucket = queue.sessions[id] || { records: [], updatedAt: 0 };
|
|
114
|
+
const seen = new Set(bucket.records.map(queueEntryKey));
|
|
115
|
+
for (const raw of list) {
|
|
116
|
+
const rec = normalizeRecord(raw);
|
|
117
|
+
if (!rec) continue;
|
|
118
|
+
const key = queueEntryKey(rec);
|
|
119
|
+
if (seen.has(key)) continue;
|
|
120
|
+
seen.add(key);
|
|
121
|
+
bucket.records.push(rec);
|
|
122
|
+
}
|
|
123
|
+
// Never persist an empty bucket (all-invalid input, or nothing new for a
|
|
124
|
+
// session that had none) — it would just be dead weight in the file.
|
|
125
|
+
if (bucket.records.length === 0) return queue;
|
|
126
|
+
if (bucket.records.length > MAX_RECORDS_PER_SESSION) {
|
|
127
|
+
bucket.records = bucket.records.slice(
|
|
128
|
+
bucket.records.length - MAX_RECORDS_PER_SESSION,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
bucket.updatedAt = Number.isFinite(Number(now))
|
|
132
|
+
? Number(now)
|
|
133
|
+
: bucket.updatedAt;
|
|
134
|
+
queue.sessions[id] = bucket;
|
|
135
|
+
boundSessions(queue);
|
|
136
|
+
return queue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* PURE fold: remove `records` (by key) from `sessionId`'s bucket (mutates +
|
|
141
|
+
* returns `queue`). Empties → the bucket is deleted so the file self-cleans.
|
|
142
|
+
*/
|
|
143
|
+
function foldRemove(queue, sessionId, records) {
|
|
144
|
+
if (!queue || !queue.sessions) return queue || emptyQueue();
|
|
145
|
+
const id = String(sessionId || "").trim();
|
|
146
|
+
const bucket = queue.sessions[id];
|
|
147
|
+
if (!bucket) return queue;
|
|
148
|
+
const kill = new Set(
|
|
149
|
+
(Array.isArray(records) ? records : [records])
|
|
150
|
+
.map(normalizeRecord)
|
|
151
|
+
.filter(Boolean)
|
|
152
|
+
.map(queueEntryKey),
|
|
153
|
+
);
|
|
154
|
+
bucket.records = bucket.records.filter((r) => !kill.has(queueEntryKey(r)));
|
|
155
|
+
if (bucket.records.length === 0) delete queue.sessions[id];
|
|
156
|
+
return queue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Keep only the MAX_SESSIONS most-recently-updated buckets. */
|
|
160
|
+
function boundSessions(queue) {
|
|
161
|
+
if (!queue || !queue.sessions) return queue;
|
|
162
|
+
const ids = Object.keys(queue.sessions);
|
|
163
|
+
if (ids.length <= MAX_SESSIONS) return queue;
|
|
164
|
+
ids
|
|
165
|
+
.sort(
|
|
166
|
+
(a, b) =>
|
|
167
|
+
(queue.sessions[a].updatedAt || 0) - (queue.sessions[b].updatedAt || 0),
|
|
168
|
+
)
|
|
169
|
+
.slice(0, ids.length - MAX_SESSIONS)
|
|
170
|
+
.forEach((id) => delete queue.sessions[id]);
|
|
171
|
+
return queue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* PURE fold: drop buckets whose `updatedAt` is older than `now - staleMs`
|
|
176
|
+
* (mutates + returns `queue`). Guards the file against unbounded growth from
|
|
177
|
+
* sessions that were never resumed to drain their pending rewakes.
|
|
178
|
+
*/
|
|
179
|
+
function foldPruneStale(queue, now, staleMs = DEFAULT_STALE_MS) {
|
|
180
|
+
if (!queue || !queue.sessions) return queue || emptyQueue();
|
|
181
|
+
const cutoff = (Number.isFinite(Number(now)) ? Number(now) : 0) - staleMs;
|
|
182
|
+
for (const [id, bucket] of Object.entries(queue.sessions)) {
|
|
183
|
+
if (!bucket || (bucket.updatedAt || 0) < cutoff) delete queue.sessions[id];
|
|
184
|
+
}
|
|
185
|
+
return queue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Best-effort read of the on-disk queue (→ empty on missing/corrupt). */
|
|
189
|
+
function loadQueue(file = defaultAsyncHookQueuePath(), fs = fsDefault) {
|
|
190
|
+
try {
|
|
191
|
+
if (!fs.existsSync(file)) return emptyQueue();
|
|
192
|
+
return coerceQueue(JSON.parse(fs.readFileSync(file, "utf-8")));
|
|
193
|
+
} catch {
|
|
194
|
+
return emptyQueue();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Best-effort atomic write (tmp + rename). Never throws. */
|
|
199
|
+
function saveQueue(queue, file = defaultAsyncHookQueuePath(), fs = fsDefault) {
|
|
200
|
+
try {
|
|
201
|
+
const dir = path.dirname(file);
|
|
202
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
203
|
+
const tmp = `${file}.tmp`;
|
|
204
|
+
fs.writeFileSync(tmp, JSON.stringify(queue), "utf-8");
|
|
205
|
+
fs.renameSync(tmp, file);
|
|
206
|
+
return true;
|
|
207
|
+
} catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Append one/more rewake records for `sessionId` and write back. Best-effort;
|
|
214
|
+
* returns true on a successful write. `now` defaults to Date.now() (the only
|
|
215
|
+
* non-pure entry point besides load/save).
|
|
216
|
+
*/
|
|
217
|
+
function appendRewake(
|
|
218
|
+
{ sessionId, records, now } = {},
|
|
219
|
+
file = defaultAsyncHookQueuePath(),
|
|
220
|
+
fs = fsDefault,
|
|
221
|
+
) {
|
|
222
|
+
const id = String(sessionId || "").trim();
|
|
223
|
+
if (!id || records == null) return false;
|
|
224
|
+
const at = Number.isFinite(Number(now)) ? Number(now) : Date.now();
|
|
225
|
+
const queue = foldAppend(loadQueue(file, fs), id, records, at);
|
|
226
|
+
return saveQueue(queue, file, fs);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Remove (mark consumed) rewake records for `sessionId` and write back.
|
|
231
|
+
* Best-effort; a no-op when nothing matches.
|
|
232
|
+
*/
|
|
233
|
+
function removeRewakes(
|
|
234
|
+
{ sessionId, records } = {},
|
|
235
|
+
file = defaultAsyncHookQueuePath(),
|
|
236
|
+
fs = fsDefault,
|
|
237
|
+
) {
|
|
238
|
+
const id = String(sessionId || "").trim();
|
|
239
|
+
if (!id || records == null) return false;
|
|
240
|
+
const queue = foldRemove(loadQueue(file, fs), id, records);
|
|
241
|
+
return saveQueue(queue, file, fs);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Load a session's pending rewakes AND clear its bucket (also prunes stale
|
|
246
|
+
* buckets while we hold the file). Returns the recovered records (possibly []).
|
|
247
|
+
* The single call a resuming runner makes on startup.
|
|
248
|
+
*/
|
|
249
|
+
function takePending(
|
|
250
|
+
{ sessionId, now, staleMs } = {},
|
|
251
|
+
file = defaultAsyncHookQueuePath(),
|
|
252
|
+
fs = fsDefault,
|
|
253
|
+
) {
|
|
254
|
+
const id = String(sessionId || "").trim();
|
|
255
|
+
if (!id) return [];
|
|
256
|
+
const at = Number.isFinite(Number(now)) ? Number(now) : Date.now();
|
|
257
|
+
let queue = loadQueue(file, fs);
|
|
258
|
+
const bucket = queue.sessions[id];
|
|
259
|
+
const recovered =
|
|
260
|
+
bucket && Array.isArray(bucket.records) ? bucket.records : [];
|
|
261
|
+
if (bucket) delete queue.sessions[id];
|
|
262
|
+
queue = foldPruneStale(queue, at, staleMs);
|
|
263
|
+
// Only rewrite if we actually changed something (recovered a bucket or the
|
|
264
|
+
// prune dropped one) — a pure read of an empty/unrelated queue leaves the
|
|
265
|
+
// file byte-unchanged.
|
|
266
|
+
if (recovered.length > 0 || bucket) saveQueue(queue, file, fs);
|
|
267
|
+
return recovered;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = {
|
|
271
|
+
defaultAsyncHookQueuePath,
|
|
272
|
+
queueEntryKey,
|
|
273
|
+
normalizeRecord,
|
|
274
|
+
emptyQueue,
|
|
275
|
+
coerceQueue,
|
|
276
|
+
foldAppend,
|
|
277
|
+
foldRemove,
|
|
278
|
+
foldPruneStale,
|
|
279
|
+
boundSessions,
|
|
280
|
+
loadQueue,
|
|
281
|
+
saveQueue,
|
|
282
|
+
appendRewake,
|
|
283
|
+
removeRewakes,
|
|
284
|
+
takePending,
|
|
285
|
+
MAX_RECORDS_PER_SESSION,
|
|
286
|
+
MAX_SESSIONS,
|
|
287
|
+
DEFAULT_STALE_MS,
|
|
288
|
+
};
|
|
@@ -73,6 +73,22 @@ class AsyncHookSupervisor {
|
|
|
73
73
|
? this._statsStore.defaultHookStatsPath()
|
|
74
74
|
: null);
|
|
75
75
|
this._statsFs = opts.statsFs || null; // injected fs for tests
|
|
76
|
+
// Persistent rewake queue (opt-in). A rewake — an async check that OPTED IN
|
|
77
|
+
// and finished in a FAILURE state — is the one actionable signal this
|
|
78
|
+
// supervisor produces, but it lives only in `_rewakes` until the turn loop
|
|
79
|
+
// drains it. If the process dies in that window the failure is lost. When a
|
|
80
|
+
// `sessionId` + `persistQueue` (or an explicit `queuePath`) are supplied,
|
|
81
|
+
// each rewake is durably parked keyed by session so `--resume` can recover
|
|
82
|
+
// it; it's removed again the moment the run drains it. Disabled (no writes,
|
|
83
|
+
// byte-unchanged) when either is absent — mirrors the stats-store opt-in.
|
|
84
|
+
this._queueStore = opts.queueStore || require("./async-hook-queue.cjs");
|
|
85
|
+
this._queueSessionId = opts.sessionId ? String(opts.sessionId) : null;
|
|
86
|
+
this._queuePath =
|
|
87
|
+
opts.queuePath ||
|
|
88
|
+
(opts.persistQueue === true
|
|
89
|
+
? this._queueStore.defaultAsyncHookQueuePath()
|
|
90
|
+
: null);
|
|
91
|
+
this._queueFs = opts.queueFs || null; // injected fs for tests
|
|
76
92
|
// The exit reaper is installed LAZILY (on first spawn), not in the ctor:
|
|
77
93
|
// a supervisor that never dispatches a hook (or the many built in tests)
|
|
78
94
|
// must not leave a permanent `process.once('exit')` listener behind —
|
|
@@ -359,6 +375,29 @@ class AsyncHookSupervisor {
|
|
|
359
375
|
if (hook.asyncRewake && !rec.ok) {
|
|
360
376
|
this._rewakes.push(rec);
|
|
361
377
|
if (this._rewakes.length > RESULT_RING) this._rewakes.shift();
|
|
378
|
+
// Durably park it (opt-in) so a crash before the turn loop drains this
|
|
379
|
+
// rewake doesn't lose the failure signal. Immediate write, not deferred to
|
|
380
|
+
// stopAll — a rewake must survive even a hard exit that never runs stopAll.
|
|
381
|
+
this._persistRewake(rec);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** True when a session id + queue location were both configured. */
|
|
386
|
+
_queueEnabled() {
|
|
387
|
+
return !!(this._queueSessionId && this._queuePath);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Best-effort durable append of one rewake record. Never throws. */
|
|
391
|
+
_persistRewake(rec) {
|
|
392
|
+
if (!this._queueEnabled()) return;
|
|
393
|
+
try {
|
|
394
|
+
this._queueStore.appendRewake(
|
|
395
|
+
{ sessionId: this._queueSessionId, records: [rec], now: rec.ts },
|
|
396
|
+
this._queuePath,
|
|
397
|
+
this._queueFs || undefined,
|
|
398
|
+
);
|
|
399
|
+
} catch {
|
|
400
|
+
/* queue is best-effort */
|
|
362
401
|
}
|
|
363
402
|
}
|
|
364
403
|
|
|
@@ -377,6 +416,19 @@ class AsyncHookSupervisor {
|
|
|
377
416
|
drainRewakes() {
|
|
378
417
|
const r = this._rewakes;
|
|
379
418
|
this._rewakes = [];
|
|
419
|
+
// The run has now consumed these rewakes (it will surface / re-engage on
|
|
420
|
+
// them), so they no longer need to survive a crash — drop the durable copies.
|
|
421
|
+
if (r.length > 0 && this._queueEnabled()) {
|
|
422
|
+
try {
|
|
423
|
+
this._queueStore.removeRewakes(
|
|
424
|
+
{ sessionId: this._queueSessionId, records: r },
|
|
425
|
+
this._queuePath,
|
|
426
|
+
this._queueFs || undefined,
|
|
427
|
+
);
|
|
428
|
+
} catch {
|
|
429
|
+
/* queue is best-effort */
|
|
430
|
+
}
|
|
431
|
+
}
|
|
380
432
|
return r;
|
|
381
433
|
}
|
|
382
434
|
|