openmausbot 0.1.86 → 0.1.87
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 +2 -2
- package/dist/assets/{abnfDiagram-O67JEVCF-DpUZNnFD.js → abnfDiagram-O67JEVCF-BTiuL79F.js} +1 -1
- package/dist/assets/{arc-DW7mrI4p.js → arc-C88Blc2v.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-CyeZ5WLZ.js → architectureDiagram-NJMV4G6O-BZsJIZrb.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-CBVaNjO7.js → blockDiagram-BEXU5L5S-CVeMe9Ah.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-sf8d41uT.js → c4Diagram-YGBWAQC7-Cedk3Bhx.js} +1 -1
- package/dist/assets/channel-taCNRlUU.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-BOGvy7Y_.js → chunk-2Q5K7J3B-D-VRqzTB.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-BiF9zyiz.js → chunk-3FUC2YCW-BChov01Y.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-EFuZ1SBZ.js → chunk-5VM5RSS4-C-TfwvHy.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-pJLNSAT7.js → chunk-GTNCS2PH-Db7CGj5Y.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-Cb-3_3IT.js → chunk-GWA4HPMP-CNvIfqKT.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-DAf5aogn.js → chunk-JWPE2WC7-C_bEp5tq.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-DR3kiwZz.js → chunk-NETBCI7D-Coi4BOls.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-BmU8PeyM.js → chunk-WEXAMYUT-CzylEuL3.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-Buh8g8OT.js → chunk-XXDRQBXY-m7p7ztaD.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-BurfmMWM.js → classDiagram-v2-NBCMYWYE-BMy6o9x2.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-KkGZUEkU.js → cose-bilkent-JH36ORCC-BDQ__0_J.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-C3PpV96d.js → cynefin-OW5HDTMX-Djv4ghUb.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-Dt8JwJaI.js → cynefinDiagram-VND7K2PF-BULrlGYH.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-DkjN8266.js → dagre-6A5THRUB-D-jrT_hU.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-DUAJS0JV.js → diagram-22UHCM2B-p2UhQmfL.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-Dw4KhF1a.js → diagram-3UASUU5V-DHvGWZ7q.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-Dd1gmQqf.js → diagram-ATOU4E4O-ChhKDot-.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-CjwGs0Yw.js → diagram-CDSNMT55-B-T7A43R.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-BL-69Zrq.js → diagram-MLGK6HIB-CJm8ENNY.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-5Zi2N7wo.js → diagram-MPIPVDR6-510tlza8.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-D9NekWUX.js → ebnfDiagram-ZINNZB2B-PkvRPHel.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-CkaSQjxw.js → elk-276RUBZZ-BVBVPoX3.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-CdAVBV8-.js → erDiagram-OPXOYQCR-BgYMVm7L.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-Bm_lWWH7.js → flowDiagram-KWPJA3E3-BGqsnc_3.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-FZ8ejYWJ.js → ganttDiagram-FUAMR5RP-D_-UeReH.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-Dlv9rizZ.js → gitGraphDiagram-X574FWY7-D0uooONx.js} +1 -1
- package/dist/assets/index-BwdMAQ6p.css +1 -0
- package/dist/assets/index-ByJennr7.js +581 -0
- package/dist/assets/{index-h9FFNwTu.js → index-zRsJERGJ.js} +1 -1
- package/dist/assets/{infoDiagram-VRGFBTTK-BdQ_gW5B.js → infoDiagram-VRGFBTTK-B9e1z77f.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-EwssEp2X.js → ishikawaDiagram-OU5B5YK6-BB4vNwvD.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-BX1G1vOd.js → journeyDiagram-ZHPQQLJL-DpkosOTB.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-BB4kAsyC.js → kanban-definition-PNTS6WVX-DbDP4kUu.js} +1 -1
- package/dist/assets/{linear-3su2TG93.js → linear-DBJGPmjT.js} +1 -1
- package/dist/assets/{mermaid.core-CDD1E0s9.js → mermaid.core-DWPRi-ue.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-Ba50MsNK.js → mindmap-definition-NLK3R4M7-nszBbJmx.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-DZLkkZlM.js → pegDiagram-GJSIUBJH-CWykJWu_.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-DaTz77Uq.js → pieDiagram-5QR66LMP-g3Da-Vnv.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-CpaiidpR.js → quadrantDiagram-O4NWA36T-Wzrlpzgh.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-mN4JrMeI.js → railroadDiagram-XR7U4H2S-moHTFMC0.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-BSNDRpg7.js → requirementDiagram-PLB6GJNP-CwfXdFH8.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-FngzGNug.js → sankeyDiagram-IPEJSGJF-3ZROYvnq.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-CM4pCC3L.js → sequenceDiagram-PO4LG4MO-E1TBebP8.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-CRICHXkw.js → sizeCapture-INFHLROL-DHKEU5pu.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-oyTWpxH2.js → stateDiagram-v2-GCMORJYK-BguEj09D.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-D9h9mPBq.js → swimlanes-2SLR337P-C7IDYnhd.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-BpEdoB2e.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-BJUlN9bW.js → timeline-definition-EJHVYXUP-DCW-F5PW.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-3uBNkbAb.js → usecaseDiagram-POWQR4AR-xBMFtWYV.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-D2odalzT.js → vennDiagram-UO4OBE2U-B5nIkuH8.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-DHgFaB9S.js → wardleyDiagram-VNRHLVJA-DYFAhdbr.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-w5GYM2h0.js → xychartDiagram-PMCCYNJV-DeXwX6Cz.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/browser-proxy.js +2 -1
- package/dist-server/container-mcp.js +1029 -164
- package/dist-server/drivers/agents-proxy.js +64 -10
- package/dist-server/drivers/phone-proxy.js +33 -2
- package/dist-server/index.js +14577 -9001
- package/dist-server/local-computer-proxy.js +3 -3
- package/dist-server/local-computer.js +53 -17
- package/dist-server/openmausbot.js +3981 -1165
- package/dist-server/pair-cli.js +3981 -1165
- package/dist-server/permission-proxy.js +29 -9
- package/dist-server/server/attachments.js +55 -0
- package/dist-server/server/auto-approve.js +4 -3
- package/dist-server/server/auto-vm-claims.js +1 -0
- package/dist-server/server/bot-package.js +18 -292
- package/dist-server/server/bot-profile.js +5 -17
- package/dist-server/server/box-create-idempotency.js +3 -3
- package/dist-server/server/box.js +57 -55
- package/dist-server/server/browser-proxy.js +3 -2
- package/dist-server/server/browser-runtime.js +37 -1
- package/dist-server/server/cli.js +2 -2
- package/dist-server/server/command-allowlist.js +154 -0
- package/dist-server/server/computer-wait.js +22 -9
- package/dist-server/server/config.js +59 -9
- package/dist-server/server/connector-verdict.js +141 -0
- package/dist-server/server/container-computer.js +1 -1
- package/dist-server/server/default-model-selection.js +9 -0
- package/dist-server/server/delegations.js +85 -34
- package/dist-server/server/drivers/acp/core.js +57 -15
- package/dist-server/server/drivers/agents-call.js +42 -3
- package/dist-server/server/drivers/agents-catalog.js +34 -4
- package/dist-server/server/drivers/boxagent.js +174 -36
- package/dist-server/server/drivers/builtIn.js +2 -0
- package/dist-server/server/drivers/chat-box-tools.js +90 -0
- package/dist-server/server/drivers/chat-images.js +54 -0
- package/dist-server/server/drivers/chat-mcp-tools.js +38 -8
- package/dist-server/server/drivers/chat-tool-approval.js +70 -18
- package/dist-server/server/drivers/claude.js +7 -2
- package/dist-server/server/drivers/codex-instructions.js +48 -6
- package/dist-server/server/drivers/codex-tool-surface.js +20 -0
- package/dist-server/server/drivers/codex.js +95 -30
- package/dist-server/server/drivers/mistral.js +115 -0
- package/dist-server/server/drivers/openai-chat.js +155 -32
- package/dist-server/server/drivers/openai-compat.js +1 -0
- package/dist-server/server/drivers/permission-command.js +35 -0
- package/dist-server/server/drivers/phone-proxy.js +47 -2
- package/dist-server/server/drivers/pi.js +75 -4
- package/dist-server/server/drivers/prompt-split.js +87 -0
- package/dist-server/server/fleet.js +1 -1
- package/dist-server/server/group-thread-usage.js +103 -0
- package/dist-server/server/harness/registry.js +1 -0
- package/dist-server/server/hosted-models.js +1 -1
- package/dist-server/server/index.js +1385 -322
- package/dist-server/server/local-computer.js +72 -23
- package/dist-server/server/managed-desktop.js +1 -1
- package/dist-server/server/mcp-bridge.js +6 -6
- package/dist-server/server/new-bot-defaults.js +115 -0
- package/dist-server/server/org-library.js +1051 -0
- package/dist-server/server/package-export.js +627 -39
- package/dist-server/server/package-import.js +458 -0
- package/dist-server/server/package-parts.js +87 -0
- package/dist-server/server/peer-approval.js +16 -7
- package/dist-server/server/peer-roster.js +10 -0
- package/dist-server/server/presets.js +492 -0
- package/dist-server/server/provider-key-check.js +3 -2
- package/dist-server/server/published-teams.js +80 -0
- package/dist-server/server/room-handoffs.js +19 -3
- package/dist-server/server/routes/bot-presets.js +25 -0
- package/dist-server/server/routine-requests.js +6 -3
- package/dist-server/server/routines.js +41 -1
- package/dist-server/server/section-context.js +10 -0
- package/dist-server/server/skills.js +34 -61
- package/dist-server/server/steer-queue.js +3 -2
- package/dist-server/server/store.js +259 -15
- package/dist-server/server/surface.js +10 -4
- package/dist-server/server/system-prompt.js +12 -4
- package/dist-server/server/team-backup.js +6 -2
- package/dist-server/server/team-computers.js +10 -0
- package/dist-server/server/team-library.js +6 -2
- package/dist-server/server/team-manifest.js +4 -3
- package/dist-server/server/team-setup-requests.js +16 -5
- package/dist-server/server/thread-events.js +17 -1
- package/dist-server/server/turn-context.js +2 -0
- package/dist-server/server/turn-watchdog.js +12 -3
- package/dist-server/server/usage-ledger.js +12 -1
- package/dist-server/server/vps-computer.js +139 -51
- package/dist-server/server/vps-container-mcp.js +7 -0
- package/dist-server/server/vps-ssh.js +36 -7
- package/dist-server/server/workspace-backup-policy.js +12 -0
- package/dist-server/server/workspace-backup.js +2 -2
- package/dist-server/server/workspace.js +1 -1
- package/dist-server/shared/approval-mode.js +1 -1
- package/dist-server/shared/ask-question.js +188 -12
- package/dist-server/shared/bot-profile.js +15 -0
- package/dist-server/shared/command-allowlist.js +1 -0
- package/dist-server/shared/credential-request.js +1 -1
- package/dist-server/shared/package-format.js +967 -0
- package/dist-server/shared/skill-md.js +64 -0
- package/dist-server/shared/team-backup.js +7 -0
- package/dist-server/shared/wire.js +4 -0
- package/dist-server/vps-container-mcp.js +1129 -170
- package/enterprise/README.md +1 -1
- package/package.json +1 -1
- package/dist/assets/channel-BXFkgus9.js +0 -1
- package/dist/assets/index-BGgqlWPJ.css +0 -1
- package/dist/assets/index-BtHo8ZKl.js +0 -577
- package/dist/assets/swimlanesDiagram-TC7HE7FX-BWrvB2ak.js +0 -8
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
// The organization library: packages an organization's Admin shares with
|
|
2
|
+
// this desktop, shown on a shelf (Templates → From {Organization}) and added
|
|
3
|
+
// with one click.
|
|
4
|
+
//
|
|
5
|
+
// Electron main owns the Admin connection (electron/managed-desktop.mjs): it
|
|
6
|
+
// fetches the catalog and every release file, checks their SHA-256, stores
|
|
7
|
+
// the files in DATA_DIR/org-library/blobs/, and relays the catalog here over
|
|
8
|
+
// the private utility port. This runtime never talks to Admin. It:
|
|
9
|
+
//
|
|
10
|
+
// - keeps the relayed catalog in memory (a sign-out relays null: the shelf
|
|
11
|
+
// disappears and everything already added stays);
|
|
12
|
+
// - adds a package through the one importer with trust "org": skills
|
|
13
|
+
// switched ON (the organization's Admin published them), routines paused,
|
|
14
|
+
// every record stamped with its install and per-part hashes;
|
|
15
|
+
// - keeps DATA_DIR/org-library/state.json as an index of what was added.
|
|
16
|
+
// The records themselves are the source of truth: the index is rebuilt
|
|
17
|
+
// from them on start, before every Add and whenever records disappear, so
|
|
18
|
+
// a crash between the two writes is adopted, never duplicated. A team Add
|
|
19
|
+
// is marked in the index before its first record, so one the app stopped
|
|
20
|
+
// halfway through is removed again rather than adopted half-built;
|
|
21
|
+
// - switches off the skills and pauses the routines of a release its
|
|
22
|
+
// publisher withdrew;
|
|
23
|
+
// - reports a full snapshot of what this desktop has back to Electron,
|
|
24
|
+
// which posts it to Admin (contract §5.5).
|
|
25
|
+
//
|
|
26
|
+
// With no organization, nothing here runs: the shelf is absent, the routes
|
|
27
|
+
// answer {organization: null}, and no file is written.
|
|
28
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, } from "node:fs";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
32
|
+
import { importPackageDocument, orgInstallId, PackageImportError, } from "./package-import.js";
|
|
33
|
+
import { pair, partHash, sha256Hex } from "./package-parts.js";
|
|
34
|
+
import { storedPresetObject } from "./presets.js";
|
|
35
|
+
import { sectionKey } from "./store.js";
|
|
36
|
+
import { PACKAGE_MAX_BYTES, PACKAGE_VERSION, PackageFormatError, parsePackageDocument, } from "../shared/package-format.js";
|
|
37
|
+
export const ORG_LIBRARY_FORMAT = "openmaus.org-library";
|
|
38
|
+
/** The catalog body cap (contract §1.8). */
|
|
39
|
+
export const ORG_LIBRARY_CATALOG_MAX_BYTES = 256 * 1024;
|
|
40
|
+
export const ORG_LIBRARY_MAX_ENTRIES = 100;
|
|
41
|
+
export const ORG_LIBRARY_REPORT_MAX_ENTRIES = 100;
|
|
42
|
+
const UUID = /^[a-f0-9-]{36}$/;
|
|
43
|
+
const HEX64 = /^[a-f0-9]{64}$/;
|
|
44
|
+
const INSTALL_ID = /^[a-f0-9]{32}$/;
|
|
45
|
+
const SEMVER = /^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/;
|
|
46
|
+
const REF = /^[a-z][a-z0-9-]{1,30}\/[a-z0-9][a-z0-9-]{0,79}$/;
|
|
47
|
+
/** Electron's safeText rule (electron/managed-desktop.mjs): no control characters. */
|
|
48
|
+
// oxlint-disable-next-line no-control-regex
|
|
49
|
+
const CONTROL = /[\x00-\x1f\x7f]/;
|
|
50
|
+
// oxlint-disable-next-line no-control-regex
|
|
51
|
+
const CONTROL_EXCEPT_LINES = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
52
|
+
const safeText = (max) => z.string().min(1).max(max).refine((value) => !CONTROL.test(value));
|
|
53
|
+
const count = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
|
54
|
+
const entrySchema = z.object({
|
|
55
|
+
packageId: z.string().regex(UUID),
|
|
56
|
+
ref: z.string().regex(REF),
|
|
57
|
+
name: safeText(100),
|
|
58
|
+
tagline: safeText(160),
|
|
59
|
+
kind: z.enum(["team", "library"]),
|
|
60
|
+
publisher: z.object({ organizationId: z.string().regex(UUID), name: safeText(100), self: z.boolean() }),
|
|
61
|
+
mode: z.enum(["required", "available", "off"]),
|
|
62
|
+
offAction: z.enum(["keep", "remove"]),
|
|
63
|
+
release: z.object({
|
|
64
|
+
version: z.string().regex(SEMVER),
|
|
65
|
+
sha256: z.string().regex(HEX64),
|
|
66
|
+
sizeBytes: z.number().int().min(1).max(PACKAGE_MAX_BYTES),
|
|
67
|
+
formatVersion: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER),
|
|
68
|
+
publishedAt: count,
|
|
69
|
+
notes: z.string().max(4_000).refine((value) => !CONTROL_EXCEPT_LINES.test(value)),
|
|
70
|
+
}).nullable(),
|
|
71
|
+
withdrawnReleases: z.array(z.object({ version: z.string().regex(SEMVER), sha256: z.string().regex(HEX64) })).max(50),
|
|
72
|
+
contents: z.object({
|
|
73
|
+
bots: count, skills: count, presets: count, rooms: count, routines: count, connections: count,
|
|
74
|
+
botNames: z.array(safeText(100)).max(12),
|
|
75
|
+
}),
|
|
76
|
+
scanFindings: count,
|
|
77
|
+
});
|
|
78
|
+
const envelopeSchema = z.object({
|
|
79
|
+
format: z.literal(ORG_LIBRARY_FORMAT),
|
|
80
|
+
version: z.literal(1),
|
|
81
|
+
libraryVersion: count,
|
|
82
|
+
organization: z.object({ id: z.string().regex(UUID), name: safeText(100) }),
|
|
83
|
+
truncated: z.literal(true).optional(),
|
|
84
|
+
packages: z.array(z.unknown()).max(ORG_LIBRARY_MAX_ENTRIES),
|
|
85
|
+
});
|
|
86
|
+
/** The catalog as §5.3 defines it. Unknown fields are ignored and a malformed
|
|
87
|
+
* entry is dropped; a malformed envelope returns null (keep the last one). */
|
|
88
|
+
export function parseOrgLibraryCatalog(value) {
|
|
89
|
+
const envelope = envelopeSchema.safeParse(value);
|
|
90
|
+
if (!envelope.success)
|
|
91
|
+
return null;
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
const packages = envelope.data.packages.flatMap((raw) => {
|
|
94
|
+
const entry = entrySchema.safeParse(raw);
|
|
95
|
+
if (!entry.success || seen.has(entry.data.packageId))
|
|
96
|
+
return [];
|
|
97
|
+
seen.add(entry.data.packageId);
|
|
98
|
+
return [entry.data];
|
|
99
|
+
});
|
|
100
|
+
return {
|
|
101
|
+
format: ORG_LIBRARY_FORMAT,
|
|
102
|
+
version: 1,
|
|
103
|
+
libraryVersion: envelope.data.libraryVersion,
|
|
104
|
+
organization: { ...envelope.data.organization },
|
|
105
|
+
...(envelope.data.truncated ? { truncated: true } : {}),
|
|
106
|
+
packages,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** An Admin origin: https, or http on loopback, with nothing after the host. */
|
|
110
|
+
function adminOrigin(value) {
|
|
111
|
+
if (typeof value !== "string" || value.length > 300)
|
|
112
|
+
return null;
|
|
113
|
+
try {
|
|
114
|
+
const url = new URL(value);
|
|
115
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
116
|
+
if (!(url.protocol === "https:" || (url.protocol === "http:" && loopback)))
|
|
117
|
+
return null;
|
|
118
|
+
return url.origin === value ? value : null;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const relaySchema = z.object({
|
|
125
|
+
adminOrigin: z.string(),
|
|
126
|
+
organizationId: z.string().regex(UUID),
|
|
127
|
+
organizationName: safeText(100).optional(),
|
|
128
|
+
digest: z.string().regex(HEX64),
|
|
129
|
+
catalog: z.unknown(),
|
|
130
|
+
});
|
|
131
|
+
// ── state.json (contract §3.4) ──────────────────────────────────────────
|
|
132
|
+
const partPair = z.object({ r: z.string().regex(HEX64), w: z.string().regex(HEX64) });
|
|
133
|
+
const installSchema = z.object({
|
|
134
|
+
packageId: z.string().regex(UUID),
|
|
135
|
+
ref: z.string().regex(REF),
|
|
136
|
+
publisher: z.object({ organizationId: z.string().regex(UUID), slug: z.string().min(1).max(40), name: z.string().min(1).max(100) }),
|
|
137
|
+
release: z.string().regex(SEMVER),
|
|
138
|
+
sha256: z.string().regex(HEX64),
|
|
139
|
+
status: z.enum(["installed", "withdrawn", "removed"]),
|
|
140
|
+
// Additive to §3.4: what kind of package, and its display name.
|
|
141
|
+
kind: z.enum(["team", "library"]).default("team"),
|
|
142
|
+
name: z.string().max(100).default(""),
|
|
143
|
+
section: z.string().max(60),
|
|
144
|
+
team: z.object({ parts: z.object({ name: partPair.optional(), brief: partPair.optional(), leader: partPair.optional() }) }),
|
|
145
|
+
bots: z.record(z.string(), z.string()),
|
|
146
|
+
rooms: z.record(z.string(), z.string()),
|
|
147
|
+
routines: z.record(z.string(), z.string()),
|
|
148
|
+
connections: z.record(z.string(), z.object({ name: z.string(), r: z.string().regex(HEX64), w: z.string().regex(HEX64) })),
|
|
149
|
+
presets: z.record(z.string(), z.object({ presetId: z.string(), r: z.string().regex(HEX64) })),
|
|
150
|
+
removedLocally: z.array(z.string()).max(1_000),
|
|
151
|
+
// Additive to §3.4: a removed team's release whose withdrawal has already
|
|
152
|
+
// switched off the copies it left (offered skills on other bots).
|
|
153
|
+
withdrawnHandled: z.string().regex(HEX64).optional(),
|
|
154
|
+
addedAt: count,
|
|
155
|
+
updatedAt: count,
|
|
156
|
+
});
|
|
157
|
+
// Additive to §3.4: a team Add that has started and not finished. Written
|
|
158
|
+
// before the first record, removed with the index write that completes it.
|
|
159
|
+
// The import is synchronous, so one found on disk means the app stopped
|
|
160
|
+
// mid-import (the importer's own rollback runs on errors only).
|
|
161
|
+
const partKeys = z.array(z.string().min(1).max(64)).max(500);
|
|
162
|
+
const addingSchema = z.object({
|
|
163
|
+
packageId: z.string().regex(UUID),
|
|
164
|
+
ref: z.string().regex(REF),
|
|
165
|
+
publisher: installSchema.shape.publisher,
|
|
166
|
+
name: z.string().max(100),
|
|
167
|
+
release: z.string().regex(SEMVER),
|
|
168
|
+
sha256: z.string().regex(HEX64),
|
|
169
|
+
startedAt: count,
|
|
170
|
+
/** What a finished import has: every bot, group chat and routine key, and the leader. */
|
|
171
|
+
expect: z.object({ bots: partKeys, rooms: partKeys, routines: partKeys, leader: z.string().min(1).max(64).nullable() }),
|
|
172
|
+
});
|
|
173
|
+
const stateSchema = z.object({
|
|
174
|
+
version: z.literal(1),
|
|
175
|
+
source: z.object({ adminOrigin: z.string(), organizationId: z.string().regex(UUID) }).nullable(),
|
|
176
|
+
appliedDigest: z.string().regex(HEX64).nullable(),
|
|
177
|
+
installs: z.record(z.string().regex(INSTALL_ID), z.unknown()),
|
|
178
|
+
adding: z.record(z.string().regex(INSTALL_ID), z.unknown()).optional(),
|
|
179
|
+
});
|
|
180
|
+
function emptyState() {
|
|
181
|
+
return { version: 1, source: null, appliedDigest: null, installs: {}, adding: {} };
|
|
182
|
+
}
|
|
183
|
+
/** A team install lives in its own bots, group chats and routines. An
|
|
184
|
+
* offered skill someone put on another bot is a copy, like any skill: it
|
|
185
|
+
* stays, but it does not keep the team "installed". */
|
|
186
|
+
function teamAlive(live) {
|
|
187
|
+
return Boolean(live && (Object.keys(live.bots).length || Object.keys(live.rooms).length || Object.keys(live.routines).length));
|
|
188
|
+
}
|
|
189
|
+
/** Contract §3.4 `removedLocally`: the parts the person deleted, so the
|
|
190
|
+
* automatic update (v1.1) can tell them from parts a new release adds and
|
|
191
|
+
* never brings them back. Before an install's bots, group chats and routines
|
|
192
|
+
* are replaced by what is left, each key that went is noted once, as
|
|
193
|
+
* `agent:<key>`, `room:<key>` or `routine:<key>`. A key that exists again
|
|
194
|
+
* (records restored from a backup) is dropped, so the list never names a
|
|
195
|
+
* part that is there. Returns whether the list changed. */
|
|
196
|
+
function noteRemovedParts(install, next) {
|
|
197
|
+
const removed = new Set(install.removedLocally);
|
|
198
|
+
const present = new Set();
|
|
199
|
+
for (const [kind, was, now] of [
|
|
200
|
+
["agent", install.bots, next.bots],
|
|
201
|
+
["room", install.rooms, next.rooms],
|
|
202
|
+
["routine", install.routines, next.routines],
|
|
203
|
+
]) {
|
|
204
|
+
for (const key of Object.keys(was))
|
|
205
|
+
if (!Object.hasOwn(now, key))
|
|
206
|
+
removed.add(`${kind}:${key}`);
|
|
207
|
+
for (const key of Object.keys(now))
|
|
208
|
+
present.add(`${kind}:${key}`);
|
|
209
|
+
}
|
|
210
|
+
const after = [...removed].filter((part) => !present.has(part));
|
|
211
|
+
if (after.length === install.removedLocally.length && after.every((part, index) => part === install.removedLocally[index]))
|
|
212
|
+
return false;
|
|
213
|
+
install.removedLocally = after;
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
const refusal = (status, code, error) => ({ ok: false, status, code, error });
|
|
217
|
+
const NOT_LISTED = "This package is not in your organization's library.";
|
|
218
|
+
const NOT_READY = "This package hasn't finished downloading yet. Try again in a minute.";
|
|
219
|
+
const NEWER_APP = "Update OpenMausBot to add this package.";
|
|
220
|
+
const ADD_FAILED = "This package could not be added, so nothing was changed. Try again, or ask your organization's admin.";
|
|
221
|
+
export class OrgLibrary {
|
|
222
|
+
deps;
|
|
223
|
+
root;
|
|
224
|
+
state;
|
|
225
|
+
library = null;
|
|
226
|
+
/** Adds that failed since the last success, by package (reported as failed). */
|
|
227
|
+
failures = new Map();
|
|
228
|
+
/** stat-keyed "this blob verified" cache for listings; content reads always re-verify. */
|
|
229
|
+
verified = new Map();
|
|
230
|
+
pending = Promise.resolve();
|
|
231
|
+
recheck = null;
|
|
232
|
+
lastPosted = null;
|
|
233
|
+
/** The install id add() is importing right now (never a crashed one). */
|
|
234
|
+
importing = null;
|
|
235
|
+
unsubscribe;
|
|
236
|
+
constructor(deps) {
|
|
237
|
+
this.deps = deps;
|
|
238
|
+
this.root = join(deps.dataDir, "org-library");
|
|
239
|
+
this.state = this.load();
|
|
240
|
+
// Records are the source of truth: reconcile the index with what exists.
|
|
241
|
+
if (this.rebuild())
|
|
242
|
+
this.save();
|
|
243
|
+
this.unsubscribe = deps.store.onChange((change) => this.onStoreChange(change));
|
|
244
|
+
}
|
|
245
|
+
dispose() {
|
|
246
|
+
this.unsubscribe();
|
|
247
|
+
if (this.recheck)
|
|
248
|
+
clearTimeout(this.recheck);
|
|
249
|
+
}
|
|
250
|
+
/** Resolves once relayed work (reconcile, withdrawn handling, report) is done. */
|
|
251
|
+
settled() {
|
|
252
|
+
return this.pending;
|
|
253
|
+
}
|
|
254
|
+
/** The last report snapshot handed to Electron (for tests and fixtures). */
|
|
255
|
+
lastReport() {
|
|
256
|
+
return this.lastPosted ? structuredClone(this.lastPosted) : null;
|
|
257
|
+
}
|
|
258
|
+
get organization() {
|
|
259
|
+
return this.library ? { ...this.library.catalog.organization } : null;
|
|
260
|
+
}
|
|
261
|
+
// ── the relay (Electron main → runtime) ───────────────────────────────
|
|
262
|
+
/** `openmausbot:managed-library`: swap the catalog and return at once; the
|
|
263
|
+
* reconcile and the report run afterwards (the relay times out at 15 s).
|
|
264
|
+
* null (sign-out, expiry, revocation) hides the shelf and changes nothing
|
|
265
|
+
* else: everything already added stays. */
|
|
266
|
+
applyRelay(value) {
|
|
267
|
+
if (value === null) {
|
|
268
|
+
this.library = null;
|
|
269
|
+
return { ok: true };
|
|
270
|
+
}
|
|
271
|
+
const relay = relaySchema.safeParse(value);
|
|
272
|
+
const origin = relay.success ? adminOrigin(relay.data.adminOrigin) : null;
|
|
273
|
+
if (!relay.success || !origin)
|
|
274
|
+
return { ok: false, error: "The organization's library could not be read." };
|
|
275
|
+
let raw = relay.data.catalog;
|
|
276
|
+
if (typeof raw === "string") {
|
|
277
|
+
// The raw body Electron applied: it must be the bytes the digest names.
|
|
278
|
+
if (Buffer.byteLength(raw, "utf8") > ORG_LIBRARY_CATALOG_MAX_BYTES || sha256Hex(raw) !== relay.data.digest) {
|
|
279
|
+
return { ok: false, error: "The organization's library could not be read." };
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
raw = JSON.parse(raw);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return { ok: false, error: "The organization's library could not be read." };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const catalog = parseOrgLibraryCatalog(raw);
|
|
289
|
+
// A malformed envelope or another organization's catalog keeps the last one.
|
|
290
|
+
if (!catalog || catalog.organization.id !== relay.data.organizationId) {
|
|
291
|
+
return { ok: false, error: "The organization's library could not be read." };
|
|
292
|
+
}
|
|
293
|
+
this.library = { adminOrigin: origin, organizationId: relay.data.organizationId, digest: relay.data.digest, catalog };
|
|
294
|
+
const applied = this.library;
|
|
295
|
+
this.pending = this.pending.then(() => new Promise((resolve) => {
|
|
296
|
+
setImmediate(() => {
|
|
297
|
+
try {
|
|
298
|
+
if (this.library === applied)
|
|
299
|
+
this.reconcile(applied);
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
console.error(`[org-library] could not apply the library: ${error instanceof Error ? error.message : String(error)}`);
|
|
303
|
+
}
|
|
304
|
+
resolve();
|
|
305
|
+
});
|
|
306
|
+
}));
|
|
307
|
+
return { ok: true };
|
|
308
|
+
}
|
|
309
|
+
reconcile(applied) {
|
|
310
|
+
this.rebuild();
|
|
311
|
+
this.handleWithdrawn();
|
|
312
|
+
this.state.source = { adminOrigin: applied.adminOrigin, organizationId: applied.organizationId };
|
|
313
|
+
this.state.appliedDigest = applied.digest;
|
|
314
|
+
this.save();
|
|
315
|
+
this.report();
|
|
316
|
+
}
|
|
317
|
+
// ── records → index ───────────────────────────────────────────────────
|
|
318
|
+
currentInstallId(packageId) {
|
|
319
|
+
return this.library ? orgInstallId(this.library.adminOrigin, this.library.organizationId, packageId) : null;
|
|
320
|
+
}
|
|
321
|
+
/** Every live record that carries an organization install id. A routine
|
|
322
|
+
* counts only while its bot exists (deleting a bot pauses, not removes,
|
|
323
|
+
* its routines). */
|
|
324
|
+
scanRecords() {
|
|
325
|
+
const { store } = this.deps;
|
|
326
|
+
const found = new Map();
|
|
327
|
+
const entry = (installId) => {
|
|
328
|
+
let value = found.get(installId);
|
|
329
|
+
if (!value) {
|
|
330
|
+
value = { bots: {}, rooms: {}, routines: {}, skills: 0, sections: [] };
|
|
331
|
+
found.set(installId, value);
|
|
332
|
+
}
|
|
333
|
+
return value;
|
|
334
|
+
};
|
|
335
|
+
for (const bot of store.bots) {
|
|
336
|
+
const stamp = bot.installedPackage;
|
|
337
|
+
if (stamp?.source === "org" && stamp.installId && INSTALL_ID.test(stamp.installId) && stamp.agentKey) {
|
|
338
|
+
const value = entry(stamp.installId);
|
|
339
|
+
value.bots[stamp.agentKey] = bot.id;
|
|
340
|
+
value.sections.push(sectionKey(bot.section));
|
|
341
|
+
value.meta ??= stamp;
|
|
342
|
+
}
|
|
343
|
+
for (const skill of this.deps.skills.stamps(bot.id))
|
|
344
|
+
entry(skill.stamp.installId).skills += 1;
|
|
345
|
+
}
|
|
346
|
+
for (const group of store.groups) {
|
|
347
|
+
if (group.installedPackage)
|
|
348
|
+
entry(group.installedPackage.installId).rooms[group.installedPackage.key] = group.id;
|
|
349
|
+
}
|
|
350
|
+
for (const routine of this.deps.routines.packageStamps()) {
|
|
351
|
+
if (store.bot(routine.botId))
|
|
352
|
+
entry(routine.stamp.installId).routines[routine.stamp.key] = routine.routineId;
|
|
353
|
+
}
|
|
354
|
+
return found;
|
|
355
|
+
}
|
|
356
|
+
/** Whether anything here came from an organization. Without an install,
|
|
357
|
+
* a stamped record or a relayed catalog there is nothing to reconcile, so
|
|
358
|
+
* an installation that never had an organization reads no skill state at
|
|
359
|
+
* all. (Skill stamps alone are read only once a catalog arrives: they are
|
|
360
|
+
* how a skills-only package is recognized after a lost state.json.) */
|
|
361
|
+
hasOrgRecords() {
|
|
362
|
+
return Object.keys(this.state.installs).length > 0 ||
|
|
363
|
+
Object.keys(this.state.adding).length > 0 ||
|
|
364
|
+
this.deps.store.bots.some((bot) => bot.installedPackage?.source === "org") ||
|
|
365
|
+
this.deps.store.groups.some((group) => group.installedPackage) ||
|
|
366
|
+
this.deps.routines.packageStamps().length > 0 ||
|
|
367
|
+
this.orgPresetRows().length > 0;
|
|
368
|
+
}
|
|
369
|
+
/** Preset rows (presets.ts) an organization install registered. */
|
|
370
|
+
orgPresetRows() {
|
|
371
|
+
return (this.deps.presets?.list() ?? []).filter((row) => row.source === "org" && INSTALL_ID.test(row.installId));
|
|
372
|
+
}
|
|
373
|
+
/** Reconcile state.json with the records. Returns whether it changed. */
|
|
374
|
+
rebuild() {
|
|
375
|
+
if (!this.library && !this.hasOrgRecords())
|
|
376
|
+
return false;
|
|
377
|
+
const now = this.deps.now?.() ?? Date.now();
|
|
378
|
+
let changed = this.resolveInterruptedAdds(now);
|
|
379
|
+
const records = this.scanRecords();
|
|
380
|
+
for (const [installId, install] of Object.entries(this.state.installs)) {
|
|
381
|
+
const live = records.get(installId);
|
|
382
|
+
if (!teamAlive(live)) {
|
|
383
|
+
// A library install creates no records of its own; it stays.
|
|
384
|
+
if (install.kind === "team" && install.status !== "removed") {
|
|
385
|
+
// Every part is noted too, so the list reads the same whether the
|
|
386
|
+
// person deleted the team at once or one bot at a time.
|
|
387
|
+
noteRemovedParts(install, { bots: {}, rooms: {}, routines: {} });
|
|
388
|
+
install.status = "removed";
|
|
389
|
+
install.bots = {};
|
|
390
|
+
install.rooms = {};
|
|
391
|
+
install.routines = {};
|
|
392
|
+
install.updatedAt = now;
|
|
393
|
+
changed = true;
|
|
394
|
+
}
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
const next = {
|
|
398
|
+
bots: live.bots,
|
|
399
|
+
rooms: live.rooms,
|
|
400
|
+
routines: live.routines,
|
|
401
|
+
section: mostCommon(live.sections) ?? install.section,
|
|
402
|
+
status: install.status === "removed" ? "installed" : install.status,
|
|
403
|
+
};
|
|
404
|
+
const noted = noteRemovedParts(install, next);
|
|
405
|
+
if (noted || JSON.stringify([install.bots, install.rooms, install.routines, install.section, install.status]) !==
|
|
406
|
+
JSON.stringify([next.bots, next.rooms, next.routines, next.section, next.status])) {
|
|
407
|
+
Object.assign(install, next, { updatedAt: now });
|
|
408
|
+
changed = true;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Records with no index entry: a crash after the records but before
|
|
412
|
+
// state.json. Adopt them once the catalog says which package they are.
|
|
413
|
+
for (const [installId, live] of records) {
|
|
414
|
+
if (this.state.installs[installId] || !this.library)
|
|
415
|
+
continue;
|
|
416
|
+
const entry = this.library.catalog.packages.find((candidate) => this.currentInstallId(candidate.packageId) === installId);
|
|
417
|
+
if (!entry)
|
|
418
|
+
continue;
|
|
419
|
+
// A team is adopted from its own records. A skills-only package has
|
|
420
|
+
// none; an offered skill carrying its stamp shows it was added.
|
|
421
|
+
if (entry.kind === "team" ? !teamAlive(live) : !live.skills)
|
|
422
|
+
continue;
|
|
423
|
+
const meta = live.meta;
|
|
424
|
+
const [slug] = entry.ref.split("/");
|
|
425
|
+
this.state.installs[installId] = {
|
|
426
|
+
packageId: entry.packageId,
|
|
427
|
+
ref: meta?.ref ?? entry.ref,
|
|
428
|
+
publisher: meta?.publisher ?? { organizationId: entry.publisher.organizationId, slug: slug, name: entry.publisher.name },
|
|
429
|
+
release: meta?.release ?? entry.release?.version ?? "0.0.0",
|
|
430
|
+
sha256: meta?.sha256 ?? entry.release?.sha256 ?? "0".repeat(64),
|
|
431
|
+
status: "installed",
|
|
432
|
+
kind: entry.kind,
|
|
433
|
+
name: meta?.name ?? entry.name,
|
|
434
|
+
section: mostCommon(live.sections) ?? "",
|
|
435
|
+
// What was written at install is not recoverable here; with no base
|
|
436
|
+
// hashes a later update treats these parts as edited and keeps them.
|
|
437
|
+
// Nor is what the person deleted before state.json was lost, so
|
|
438
|
+
// removedLocally starts empty (docs/org-library.md, known limit).
|
|
439
|
+
team: { parts: {} },
|
|
440
|
+
bots: live.bots,
|
|
441
|
+
rooms: live.rooms,
|
|
442
|
+
routines: live.routines,
|
|
443
|
+
connections: {},
|
|
444
|
+
presets: {},
|
|
445
|
+
removedLocally: [],
|
|
446
|
+
addedAt: now,
|
|
447
|
+
updatedAt: now,
|
|
448
|
+
};
|
|
449
|
+
changed = true;
|
|
450
|
+
}
|
|
451
|
+
if (this.indexPresets(now))
|
|
452
|
+
changed = true;
|
|
453
|
+
return changed;
|
|
454
|
+
}
|
|
455
|
+
/** Preset rows never keep a team "installed" (its bots, group chats and
|
|
456
|
+
* routines do), but they are a skills-and-presets package's only records.
|
|
457
|
+
* Such an install whose index entry was lost (the app stopped between
|
|
458
|
+
* presets.json and state.json, or state.json could not be read) is adopted
|
|
459
|
+
* from them, so Add stays a no-op, its skills stay offered and a
|
|
460
|
+
* withdrawal still reaches its presets. An adopted install of either kind
|
|
461
|
+
* gets its presets back in the index. */
|
|
462
|
+
indexPresets(now) {
|
|
463
|
+
const byInstall = new Map();
|
|
464
|
+
for (const row of this.orgPresetRows())
|
|
465
|
+
byInstall.set(row.installId, [...(byInstall.get(row.installId) ?? []), row]);
|
|
466
|
+
let changed = false;
|
|
467
|
+
for (const [installId, rows] of byInstall) {
|
|
468
|
+
let install = this.state.installs[installId];
|
|
469
|
+
if (!install) {
|
|
470
|
+
const entry = this.library?.catalog.packages.find((candidate) => this.currentInstallId(candidate.packageId) === installId);
|
|
471
|
+
// A team is adopted from its own records (above), never from presets alone.
|
|
472
|
+
if (!entry || entry.kind !== "library")
|
|
473
|
+
continue;
|
|
474
|
+
const row = rows[0];
|
|
475
|
+
const [slug] = entry.ref.split("/");
|
|
476
|
+
const adopted = installSchema.safeParse({
|
|
477
|
+
packageId: entry.packageId,
|
|
478
|
+
ref: row.ref ?? entry.ref,
|
|
479
|
+
publisher: row.publisher ?? { organizationId: entry.publisher.organizationId, slug, name: entry.publisher.name },
|
|
480
|
+
release: row.release,
|
|
481
|
+
sha256: row.sha256 ?? entry.release?.sha256,
|
|
482
|
+
status: "installed", kind: "library", name: entry.name, section: "", team: { parts: {} },
|
|
483
|
+
bots: {}, rooms: {}, routines: {}, connections: {}, presets: {}, removedLocally: [], addedAt: now, updatedAt: now,
|
|
484
|
+
});
|
|
485
|
+
if (!adopted.success)
|
|
486
|
+
continue;
|
|
487
|
+
install = this.state.installs[installId] = adopted.data;
|
|
488
|
+
changed = true;
|
|
489
|
+
}
|
|
490
|
+
if (Object.keys(install.presets).length)
|
|
491
|
+
continue;
|
|
492
|
+
// A row is the preset exactly as the release carried it (§1.6).
|
|
493
|
+
install.presets = Object.fromEntries(rows.map((row) => [row.key, { presetId: row.id, r: partHash(storedPresetObject(row)) }]));
|
|
494
|
+
install.updatedAt = now;
|
|
495
|
+
changed = true;
|
|
496
|
+
}
|
|
497
|
+
return changed;
|
|
498
|
+
}
|
|
499
|
+
/** Team Adds the app never finished (it stopped mid-import). A team whose
|
|
500
|
+
* records are all there is indexed, as any crash after the records is
|
|
501
|
+
* (§3.3). A partial one is removed again, so the person can add it whole
|
|
502
|
+
* from the shelf instead of keeping half a team that Add calls added. */
|
|
503
|
+
resolveInterruptedAdds(now) {
|
|
504
|
+
const pending = Object.entries(this.state.adding).filter(([installId]) => installId !== this.importing);
|
|
505
|
+
if (!pending.length)
|
|
506
|
+
return false;
|
|
507
|
+
const records = this.scanRecords();
|
|
508
|
+
for (const [installId, add] of pending) {
|
|
509
|
+
const live = records.get(installId);
|
|
510
|
+
if (live && this.finished(add, live)) {
|
|
511
|
+
const previous = this.state.installs[installId];
|
|
512
|
+
this.state.installs[installId] = {
|
|
513
|
+
packageId: add.packageId, ref: add.ref, publisher: { ...add.publisher }, release: add.release, sha256: add.sha256,
|
|
514
|
+
status: "installed", kind: "team", name: add.name, section: mostCommon(live.sections) ?? "",
|
|
515
|
+
// As with any adopted install, the team-part and connection hashes
|
|
516
|
+
// were only in the index write that never happened.
|
|
517
|
+
team: { parts: {} }, bots: live.bots, rooms: live.rooms, routines: live.routines, connections: {}, presets: {},
|
|
518
|
+
removedLocally: [], addedAt: previous?.addedAt ?? add.startedAt, updatedAt: now,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
if (live && teamAlive(live))
|
|
523
|
+
this.discardPartial(installId, live);
|
|
524
|
+
this.failures.set(add.packageId, { packageId: add.packageId, release: add.release, sha256: add.sha256, state: "failed", reason: "import_failed" });
|
|
525
|
+
}
|
|
526
|
+
delete this.state.adding[installId];
|
|
527
|
+
}
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
/** Every bot (with its skills and starter notes), group chat and routine
|
|
531
|
+
* the Add would have written, and its leader, which is set last. */
|
|
532
|
+
finished(add, live) {
|
|
533
|
+
const { store } = this.deps;
|
|
534
|
+
return add.expect.bots.every((key) => Boolean(live.bots[key] && store.bot(live.bots[key])?.packageBase)) &&
|
|
535
|
+
add.expect.rooms.every((key) => Boolean(live.rooms[key])) &&
|
|
536
|
+
add.expect.routines.every((key) => Boolean(live.routines[key])) &&
|
|
537
|
+
(add.expect.leader === null || store.bot(live.bots[add.expect.leader] ?? "")?.chiefOfStaff === true);
|
|
538
|
+
}
|
|
539
|
+
/** The importer's rollback, for an import the app stopped in the middle
|
|
540
|
+
* of: its routines, group chats and bots go, and its new team with them.
|
|
541
|
+
* Every one of its routines belongs to one of its new bots, and a group
|
|
542
|
+
* chat it had not stamped yet has only those bots in that team. */
|
|
543
|
+
discardPartial(installId, live) {
|
|
544
|
+
const { store, routines } = this.deps;
|
|
545
|
+
const botIds = new Set(Object.values(live.bots));
|
|
546
|
+
for (const routine of routines.listRoutines()) {
|
|
547
|
+
if (botIds.has(routine.botId))
|
|
548
|
+
routines.remove(routine.id);
|
|
549
|
+
}
|
|
550
|
+
const section = mostCommon(live.sections);
|
|
551
|
+
const groups = store.groups.filter((group) => group.installedPackage?.installId === installId ||
|
|
552
|
+
(!group.installedPackage && section !== undefined && sectionKey(group.section) === section &&
|
|
553
|
+
group.memberIds.length > 0 && group.memberIds.every((id) => botIds.has(id))));
|
|
554
|
+
for (const group of groups)
|
|
555
|
+
store.deleteGroup(group.id);
|
|
556
|
+
for (const id of botIds)
|
|
557
|
+
store.deleteBot(id);
|
|
558
|
+
// Only if nothing else is in it (the store refuses otherwise).
|
|
559
|
+
if (section && store.sections.includes(section))
|
|
560
|
+
store.changeEmptySection(section, null);
|
|
561
|
+
console.warn(`[org-library] removed a team that was only partly added when OpenMausBot stopped; it can be added again from the shelf`);
|
|
562
|
+
}
|
|
563
|
+
/** A release its publisher withdrew: its skills off, its routines paused,
|
|
564
|
+
* its status "withdrawn". Only the transition acts, so a person who
|
|
565
|
+
* switches something back on afterwards is not overruled again. A team
|
|
566
|
+
* the person already removed stays "removed", but an offered skill it
|
|
567
|
+
* left on another bot is switched off too, once. */
|
|
568
|
+
handleWithdrawn() {
|
|
569
|
+
if (!this.library)
|
|
570
|
+
return false;
|
|
571
|
+
let changed = false;
|
|
572
|
+
for (const [installId, install] of Object.entries(this.state.installs)) {
|
|
573
|
+
if (this.currentInstallId(install.packageId) !== installId)
|
|
574
|
+
continue;
|
|
575
|
+
if (install.status === "withdrawn" || (install.status === "removed" && install.withdrawnHandled === install.sha256))
|
|
576
|
+
continue;
|
|
577
|
+
const entry = this.library.catalog.packages.find((candidate) => candidate.packageId === install.packageId);
|
|
578
|
+
if (!entry?.withdrawnReleases.some((withdrawn) => withdrawn.sha256 === install.sha256))
|
|
579
|
+
continue;
|
|
580
|
+
this.switchOff(installId);
|
|
581
|
+
if (install.status === "installed")
|
|
582
|
+
install.status = "withdrawn";
|
|
583
|
+
else
|
|
584
|
+
install.withdrawnHandled = install.sha256;
|
|
585
|
+
install.updatedAt = this.deps.now?.() ?? Date.now();
|
|
586
|
+
changed = true;
|
|
587
|
+
}
|
|
588
|
+
return changed;
|
|
589
|
+
}
|
|
590
|
+
switchOff(installId) {
|
|
591
|
+
for (const bot of this.deps.store.bots) {
|
|
592
|
+
for (const skill of this.deps.skills.stamps(bot.id)) {
|
|
593
|
+
if (skill.stamp.installId !== installId || !skill.enabled)
|
|
594
|
+
continue;
|
|
595
|
+
const result = this.deps.skills.setEnabled(bot.id, skill.name, false);
|
|
596
|
+
if ("error" in result)
|
|
597
|
+
console.warn(`[org-library] could not switch off ${skill.name}: ${result.error}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// A bot made from one of the install's presets (presets.ts) got the
|
|
601
|
+
// preset's skills under the release's organization source, unstamped.
|
|
602
|
+
for (const bot of this.deps.store.bots) {
|
|
603
|
+
const made = bot.installedPackage;
|
|
604
|
+
if (made?.source !== "org" || made.installId !== installId || !made.presetKey || !made.ref)
|
|
605
|
+
continue;
|
|
606
|
+
const source = `org:${made.ref}@${made.release}`;
|
|
607
|
+
for (const skill of this.deps.skills.list(bot.id)) {
|
|
608
|
+
if (skill.source !== source || !skill.enabled)
|
|
609
|
+
continue;
|
|
610
|
+
const result = this.deps.skills.setEnabled(bot.id, skill.name, false);
|
|
611
|
+
if ("error" in result)
|
|
612
|
+
console.warn(`[org-library] could not switch off ${skill.name}: ${result.error}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
for (const routine of this.deps.routines.packageStamps()) {
|
|
616
|
+
if (routine.stamp.installId !== installId || !routine.enabled)
|
|
617
|
+
continue;
|
|
618
|
+
try {
|
|
619
|
+
this.deps.routines.update(routine.routineId, { enabled: false });
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
console.warn(`[org-library] could not pause a routine: ${error instanceof Error ? error.message : String(error)}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/** Bots, rooms or routines went away: when that removes an install's
|
|
627
|
+
* last record, the index and the report say so. Debounced. */
|
|
628
|
+
onStoreChange(change) {
|
|
629
|
+
if (change.type !== "bot.deleted" && change.type !== "group.deleted" && change.type !== "sections")
|
|
630
|
+
return;
|
|
631
|
+
if (!Object.keys(this.state.installs).length)
|
|
632
|
+
return;
|
|
633
|
+
if (this.recheck)
|
|
634
|
+
clearTimeout(this.recheck);
|
|
635
|
+
this.recheck = setTimeout(() => {
|
|
636
|
+
this.recheck = null;
|
|
637
|
+
this.pending = this.pending.then(() => {
|
|
638
|
+
try {
|
|
639
|
+
if (this.rebuild()) {
|
|
640
|
+
this.save();
|
|
641
|
+
this.report();
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
catch (error) {
|
|
645
|
+
console.error(`[org-library] could not update the library index: ${error instanceof Error ? error.message : String(error)}`);
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
}, 250);
|
|
649
|
+
this.recheck.unref?.();
|
|
650
|
+
}
|
|
651
|
+
// ── report ────────────────────────────────────────────────────────────
|
|
652
|
+
/** The full snapshot for the current organization (contract §5.5). */
|
|
653
|
+
reportEntries() {
|
|
654
|
+
const entries = [];
|
|
655
|
+
const reported = new Set();
|
|
656
|
+
for (const [installId, install] of Object.entries(this.state.installs)) {
|
|
657
|
+
if (this.currentInstallId(install.packageId) !== installId)
|
|
658
|
+
continue;
|
|
659
|
+
reported.add(install.packageId);
|
|
660
|
+
entries.push({
|
|
661
|
+
packageId: install.packageId,
|
|
662
|
+
release: install.release,
|
|
663
|
+
sha256: install.sha256,
|
|
664
|
+
state: install.status,
|
|
665
|
+
...(install.status === "withdrawn" ? { reason: "withdrawn_by_publisher" } : {}),
|
|
666
|
+
...(install.status === "removed" ? { reason: "removed_locally" } : {}),
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
for (const failure of this.failures.values())
|
|
670
|
+
if (!reported.has(failure.packageId))
|
|
671
|
+
entries.push({ ...failure });
|
|
672
|
+
return entries.slice(0, ORG_LIBRARY_REPORT_MAX_ENTRIES);
|
|
673
|
+
}
|
|
674
|
+
report() {
|
|
675
|
+
if (!this.library)
|
|
676
|
+
return;
|
|
677
|
+
const message = {
|
|
678
|
+
type: "openmausbot:managed-library-state",
|
|
679
|
+
digest: this.library.digest,
|
|
680
|
+
packages: this.reportEntries(),
|
|
681
|
+
};
|
|
682
|
+
this.lastPosted = message;
|
|
683
|
+
try {
|
|
684
|
+
this.deps.postState?.(message);
|
|
685
|
+
}
|
|
686
|
+
catch (error) {
|
|
687
|
+
console.warn(`[org-library] could not send the library report: ${error instanceof Error ? error.message : String(error)}`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// ── files ─────────────────────────────────────────────────────────────
|
|
691
|
+
statePath() {
|
|
692
|
+
return join(this.root, "state.json");
|
|
693
|
+
}
|
|
694
|
+
load() {
|
|
695
|
+
let text;
|
|
696
|
+
try {
|
|
697
|
+
text = readFileSync(this.statePath(), "utf8");
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
return emptyState();
|
|
701
|
+
}
|
|
702
|
+
try {
|
|
703
|
+
const parsed = stateSchema.safeParse(JSON.parse(text));
|
|
704
|
+
if (!parsed.success)
|
|
705
|
+
throw new Error("invalid");
|
|
706
|
+
const installs = {};
|
|
707
|
+
for (const [installId, raw] of Object.entries(parsed.data.installs)) {
|
|
708
|
+
const install = installSchema.safeParse(raw);
|
|
709
|
+
if (install.success)
|
|
710
|
+
installs[installId] = install.data;
|
|
711
|
+
}
|
|
712
|
+
const adding = {};
|
|
713
|
+
for (const [installId, raw] of Object.entries(parsed.data.adding ?? {})) {
|
|
714
|
+
const add = addingSchema.safeParse(raw);
|
|
715
|
+
if (add.success)
|
|
716
|
+
adding[installId] = add.data;
|
|
717
|
+
}
|
|
718
|
+
const origin = parsed.data.source ? adminOrigin(parsed.data.source.adminOrigin) : null;
|
|
719
|
+
return {
|
|
720
|
+
version: 1,
|
|
721
|
+
source: parsed.data.source && origin ? { adminOrigin: origin, organizationId: parsed.data.source.organizationId } : null,
|
|
722
|
+
appliedDigest: parsed.data.appliedDigest,
|
|
723
|
+
installs,
|
|
724
|
+
adding,
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
catch {
|
|
728
|
+
// The records are the source of truth; an unreadable index is rebuilt.
|
|
729
|
+
console.warn("[org-library] state.json could not be read; rebuilding it from your bots, rooms and routines");
|
|
730
|
+
return emptyState();
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
save() {
|
|
734
|
+
mkdirSync(this.root, { recursive: true, mode: 0o700 });
|
|
735
|
+
writeFileAtomic(this.statePath(), `${JSON.stringify(this.state, null, 2)}\n`, { mode: 0o600 });
|
|
736
|
+
}
|
|
737
|
+
blobPath(sha256) {
|
|
738
|
+
return join(this.root, "blobs", `${sha256}.json`);
|
|
739
|
+
}
|
|
740
|
+
/** The release bytes, only if they hash to `sha256`. Never follows a link. */
|
|
741
|
+
readBlob(sha256) {
|
|
742
|
+
if (!HEX64.test(sha256))
|
|
743
|
+
return null;
|
|
744
|
+
let descriptor = null;
|
|
745
|
+
try {
|
|
746
|
+
const path = this.blobPath(sha256);
|
|
747
|
+
const before = lstatSync(path);
|
|
748
|
+
if (!before.isFile() || before.size > PACKAGE_MAX_BYTES)
|
|
749
|
+
return null;
|
|
750
|
+
descriptor = openSync(path, constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW ?? 0));
|
|
751
|
+
const opened = fstatSync(descriptor);
|
|
752
|
+
if (!opened.isFile() || opened.size > PACKAGE_MAX_BYTES || opened.ino !== before.ino)
|
|
753
|
+
return null;
|
|
754
|
+
const bytes = readFileSync(descriptor);
|
|
755
|
+
return sha256Hex(bytes) === sha256 ? bytes : null;
|
|
756
|
+
}
|
|
757
|
+
catch {
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
finally {
|
|
761
|
+
if (descriptor !== null) {
|
|
762
|
+
try {
|
|
763
|
+
closeSync(descriptor);
|
|
764
|
+
}
|
|
765
|
+
catch { }
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
blobStatus(entry) {
|
|
770
|
+
if (!entry.release)
|
|
771
|
+
return "unavailable";
|
|
772
|
+
if (entry.release.formatVersion > PACKAGE_VERSION)
|
|
773
|
+
return "unsupported";
|
|
774
|
+
const sha = entry.release.sha256;
|
|
775
|
+
let key;
|
|
776
|
+
try {
|
|
777
|
+
const stat = lstatSync(this.blobPath(sha));
|
|
778
|
+
if (!stat.isFile())
|
|
779
|
+
return "unavailable";
|
|
780
|
+
key = `${stat.size}:${stat.mtimeMs}:${stat.ino}`;
|
|
781
|
+
}
|
|
782
|
+
catch {
|
|
783
|
+
return "unavailable";
|
|
784
|
+
}
|
|
785
|
+
if (this.verified.get(sha) === key)
|
|
786
|
+
return "ready";
|
|
787
|
+
if (!this.readBlob(sha))
|
|
788
|
+
return "unavailable";
|
|
789
|
+
this.verified.set(sha, key);
|
|
790
|
+
return "ready";
|
|
791
|
+
}
|
|
792
|
+
/** The verified release, parsed as the organization channel reads it. */
|
|
793
|
+
releaseDocument(entry) {
|
|
794
|
+
if (!entry.release)
|
|
795
|
+
return refusal(404, "no_release", "This package has no release to add right now.");
|
|
796
|
+
if (entry.release.formatVersion > PACKAGE_VERSION)
|
|
797
|
+
return refusal(409, "newer_app_required", NEWER_APP);
|
|
798
|
+
const bytes = this.readBlob(entry.release.sha256);
|
|
799
|
+
if (!bytes)
|
|
800
|
+
return refusal(503, "blob_unavailable", NOT_READY);
|
|
801
|
+
let document;
|
|
802
|
+
try {
|
|
803
|
+
document = parsePackageDocument(JSON.parse(bytes.toString("utf8")), { trust: "org" });
|
|
804
|
+
}
|
|
805
|
+
catch (error) {
|
|
806
|
+
if (error instanceof PackageFormatError && error.code === "newer_version")
|
|
807
|
+
return refusal(409, "newer_app_required", NEWER_APP);
|
|
808
|
+
return refusal(422, "invalid_package", `This package could not be read: ${error instanceof Error ? error.message : "it is not a valid package"}`);
|
|
809
|
+
}
|
|
810
|
+
const [publisherSlug, packageSlug] = entry.ref.split("/");
|
|
811
|
+
if (document.package.id !== packageSlug || document.package.publisher?.organization !== publisherSlug) {
|
|
812
|
+
return refusal(422, "invalid_package", "This package is not the one your organization listed.");
|
|
813
|
+
}
|
|
814
|
+
return { ok: true, status: 200, value: document };
|
|
815
|
+
}
|
|
816
|
+
visibleEntry(packageId) {
|
|
817
|
+
const entry = this.library?.catalog.packages.find((candidate) => candidate.packageId === packageId);
|
|
818
|
+
return entry && entry.mode !== "off" ? entry : null;
|
|
819
|
+
}
|
|
820
|
+
listEntry(entry) {
|
|
821
|
+
const installId = this.currentInstallId(entry.packageId);
|
|
822
|
+
const install = this.state.installs[installId];
|
|
823
|
+
return {
|
|
824
|
+
...structuredClone(entry),
|
|
825
|
+
blob: this.blobStatus(entry),
|
|
826
|
+
installed: install ? { installId, release: install.release, status: install.status } : null,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
// ── renderer routes ───────────────────────────────────────────────────
|
|
830
|
+
/** GET /api/org-library. With no organization: {organization: null, packages: []}. */
|
|
831
|
+
list() {
|
|
832
|
+
if (!this.library)
|
|
833
|
+
return { organization: null, packages: [] };
|
|
834
|
+
return {
|
|
835
|
+
organization: { ...this.library.catalog.organization },
|
|
836
|
+
packages: this.library.catalog.packages.filter((entry) => entry.mode !== "off").map((entry) => this.listEntry(entry)),
|
|
837
|
+
installs: Object.entries(this.state.installs)
|
|
838
|
+
.filter(([installId, install]) => this.currentInstallId(install.packageId) === installId)
|
|
839
|
+
.map(([installId, install]) => ({
|
|
840
|
+
installId, packageId: install.packageId, name: install.name, release: install.release, status: install.status, publisher: install.publisher.name,
|
|
841
|
+
})),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
/** GET /api/org-library/packages/:packageId — the preview, from the verified file. */
|
|
845
|
+
preview(packageId) {
|
|
846
|
+
const entry = this.visibleEntry(packageId);
|
|
847
|
+
if (!entry)
|
|
848
|
+
return refusal(404, "not_listed", NOT_LISTED);
|
|
849
|
+
const document = this.releaseDocument(entry);
|
|
850
|
+
if (!document.ok)
|
|
851
|
+
return document;
|
|
852
|
+
return { ok: true, status: 200, value: { package: this.listEntry(entry), document: document.value } };
|
|
853
|
+
}
|
|
854
|
+
/** POST /api/org-library/add — one click, no confirmation: the person's
|
|
855
|
+
* click is the decision. Idempotent per install id. */
|
|
856
|
+
add(packageId, importDeps) {
|
|
857
|
+
const library = this.library;
|
|
858
|
+
const entry = this.visibleEntry(packageId);
|
|
859
|
+
if (!library || !entry)
|
|
860
|
+
return refusal(404, "not_listed", NOT_LISTED);
|
|
861
|
+
const installId = this.currentInstallId(packageId);
|
|
862
|
+
if (this.rebuild())
|
|
863
|
+
this.save();
|
|
864
|
+
const existing = this.state.installs[installId];
|
|
865
|
+
if (existing && existing.status !== "removed")
|
|
866
|
+
return { ok: true, status: 200, value: { alreadyAdded: true, installId } };
|
|
867
|
+
const fail = (outcome, reason) => {
|
|
868
|
+
if (entry.release) {
|
|
869
|
+
this.failures.set(packageId, { packageId, release: entry.release.version, sha256: entry.release.sha256, state: "failed", reason });
|
|
870
|
+
this.report();
|
|
871
|
+
}
|
|
872
|
+
return outcome;
|
|
873
|
+
};
|
|
874
|
+
const document = this.releaseDocument(entry);
|
|
875
|
+
if (!document.ok) {
|
|
876
|
+
if (document.code === "no_release")
|
|
877
|
+
return document;
|
|
878
|
+
return fail(document, document.code === "blob_unavailable" ? "blob_unavailable" : document.code === "newer_app_required" ? "newer_app_required" : "invalid_package");
|
|
879
|
+
}
|
|
880
|
+
const release = entry.release;
|
|
881
|
+
const [slug] = entry.ref.split("/");
|
|
882
|
+
const publisher = { organizationId: entry.publisher.organizationId, slug: slug, name: entry.publisher.name };
|
|
883
|
+
const pkg = document.value.package;
|
|
884
|
+
// A team's Add is marked before its first record is written, so if the
|
|
885
|
+
// app stops mid-import the next start finishes the job one way or the
|
|
886
|
+
// other (resolveInterruptedAdds). A skills-only package writes nothing.
|
|
887
|
+
const pending = pkg.agents.length > 0 && Boolean(pkg.team);
|
|
888
|
+
const settle = () => {
|
|
889
|
+
if (!pending)
|
|
890
|
+
return;
|
|
891
|
+
delete this.state.adding[installId];
|
|
892
|
+
this.importing = null;
|
|
893
|
+
};
|
|
894
|
+
if (pending) {
|
|
895
|
+
this.state.adding[installId] = {
|
|
896
|
+
packageId, ref: entry.ref, publisher, name: entry.name, release: release.version, sha256: release.sha256,
|
|
897
|
+
startedAt: this.deps.now?.() ?? Date.now(),
|
|
898
|
+
expect: {
|
|
899
|
+
bots: pkg.agents.map((agent) => agent.key),
|
|
900
|
+
rooms: (pkg.rooms ?? []).map((room) => room.key),
|
|
901
|
+
routines: (pkg.routines ?? []).map((routine) => routine.key),
|
|
902
|
+
leader: pkg.team?.leader ?? null,
|
|
903
|
+
},
|
|
904
|
+
};
|
|
905
|
+
try {
|
|
906
|
+
this.save();
|
|
907
|
+
}
|
|
908
|
+
catch (error) {
|
|
909
|
+
delete this.state.adding[installId];
|
|
910
|
+
console.error(`[org-library] could not start adding ${entry.ref}: ${error instanceof Error ? error.message : String(error)}`);
|
|
911
|
+
return fail(refusal(500, "import_failed", ADD_FAILED), "import_failed");
|
|
912
|
+
}
|
|
913
|
+
this.importing = installId;
|
|
914
|
+
}
|
|
915
|
+
let result;
|
|
916
|
+
try {
|
|
917
|
+
const imported = importPackageDocument(document.value, {
|
|
918
|
+
trust: "org",
|
|
919
|
+
mode: "add",
|
|
920
|
+
org: { installId, ref: entry.ref, packageId, sha256: release.sha256, publisher, adminOrigin: library.adminOrigin, organizationId: library.organizationId },
|
|
921
|
+
}, importDeps);
|
|
922
|
+
if (imported.alreadyAdded) {
|
|
923
|
+
settle();
|
|
924
|
+
this.rebuild();
|
|
925
|
+
this.save();
|
|
926
|
+
return { ok: true, status: 200, value: { alreadyAdded: true, installId } };
|
|
927
|
+
}
|
|
928
|
+
result = imported;
|
|
929
|
+
}
|
|
930
|
+
catch (error) {
|
|
931
|
+
// The importer removed everything it created.
|
|
932
|
+
settle();
|
|
933
|
+
try {
|
|
934
|
+
this.save();
|
|
935
|
+
}
|
|
936
|
+
catch { }
|
|
937
|
+
if (error instanceof PackageImportError)
|
|
938
|
+
return fail(refusal(422, error.code, error.message), "invalid_package");
|
|
939
|
+
console.error(`[org-library] could not add ${entry.ref}: ${error instanceof Error ? error.message : String(error)}`);
|
|
940
|
+
return fail(refusal(500, "import_failed", ADD_FAILED), "import_failed");
|
|
941
|
+
}
|
|
942
|
+
const now = this.deps.now?.() ?? Date.now();
|
|
943
|
+
const index = result.org;
|
|
944
|
+
settle();
|
|
945
|
+
this.state.installs[installId] = {
|
|
946
|
+
packageId,
|
|
947
|
+
ref: entry.ref,
|
|
948
|
+
publisher,
|
|
949
|
+
release: release.version,
|
|
950
|
+
sha256: release.sha256,
|
|
951
|
+
status: "installed",
|
|
952
|
+
kind: index.kind,
|
|
953
|
+
name: entry.name,
|
|
954
|
+
section: index.section,
|
|
955
|
+
team: { parts: index.team.parts },
|
|
956
|
+
bots: index.bots,
|
|
957
|
+
rooms: index.rooms,
|
|
958
|
+
routines: index.routines,
|
|
959
|
+
connections: index.connections,
|
|
960
|
+
// Presets now in New bot (presets.ts), with the release-side hash (§1.6).
|
|
961
|
+
presets: Object.fromEntries((result.presets ?? []).flatMap((preset) => {
|
|
962
|
+
const released = document.value.package.presets?.find((candidate) => candidate.key === preset.key);
|
|
963
|
+
return released ? [[preset.key, { presetId: preset.id, r: partHash(released) }]] : [];
|
|
964
|
+
})),
|
|
965
|
+
removedLocally: [],
|
|
966
|
+
addedAt: now,
|
|
967
|
+
updatedAt: now,
|
|
968
|
+
};
|
|
969
|
+
this.failures.delete(packageId);
|
|
970
|
+
// The records were written first; the index follows, and clears the mark.
|
|
971
|
+
this.save();
|
|
972
|
+
this.report();
|
|
973
|
+
return { ok: true, status: 201, value: { alreadyAdded: false, result } };
|
|
974
|
+
}
|
|
975
|
+
/** The skills installs offer without putting them on a bot (library
|
|
976
|
+
* packages, and a team's unassigned skills): Bot → Skills → From {Org}. */
|
|
977
|
+
offeredSkills(botId) {
|
|
978
|
+
if (!this.library)
|
|
979
|
+
return { organization: null, skills: [] };
|
|
980
|
+
const have = new Set(botId && this.deps.store.bot(botId) ? this.deps.skills.list(botId).map((skill) => skill.name) : []);
|
|
981
|
+
const skills = [];
|
|
982
|
+
for (const [installId, install] of Object.entries(this.state.installs)) {
|
|
983
|
+
if (install.status !== "installed" || this.currentInstallId(install.packageId) !== installId)
|
|
984
|
+
continue;
|
|
985
|
+
const document = this.installDocument(install);
|
|
986
|
+
if (!document)
|
|
987
|
+
continue;
|
|
988
|
+
const referenced = new Set(document.package.agents.flatMap((agent) => agent.skills ?? []));
|
|
989
|
+
for (const skill of document.package.skills?.entries ?? []) {
|
|
990
|
+
if (referenced.has(skill.name))
|
|
991
|
+
continue;
|
|
992
|
+
skills.push({
|
|
993
|
+
installId, packageName: install.name || document.package.name, publisher: install.publisher.name, release: install.release,
|
|
994
|
+
name: skill.name, description: skill.description, added: have.has(skill.name),
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
return { organization: { ...this.library.catalog.organization }, skills };
|
|
999
|
+
}
|
|
1000
|
+
installDocument(install) {
|
|
1001
|
+
const bytes = this.readBlob(install.sha256);
|
|
1002
|
+
if (!bytes)
|
|
1003
|
+
return null;
|
|
1004
|
+
try {
|
|
1005
|
+
return parsePackageDocument(JSON.parse(bytes.toString("utf8")), { trust: "org" });
|
|
1006
|
+
}
|
|
1007
|
+
catch {
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
/** Put one offered skill on a bot, switched on, stamped with its install. */
|
|
1012
|
+
addOfferedSkill(botId, installId, name) {
|
|
1013
|
+
if (!this.library)
|
|
1014
|
+
return refusal(404, "not_listed", NOT_LISTED);
|
|
1015
|
+
const bot = this.deps.store.bot(botId);
|
|
1016
|
+
if (!bot)
|
|
1017
|
+
return refusal(404, "no_bot", "That bot no longer exists.");
|
|
1018
|
+
const install = this.state.installs[installId];
|
|
1019
|
+
if (!install || this.currentInstallId(install.packageId) !== installId || install.status !== "installed") {
|
|
1020
|
+
return refusal(404, "not_listed", NOT_LISTED);
|
|
1021
|
+
}
|
|
1022
|
+
const document = this.installDocument(install);
|
|
1023
|
+
if (!document)
|
|
1024
|
+
return refusal(503, "blob_unavailable", NOT_READY);
|
|
1025
|
+
const referenced = new Set(document.package.agents.flatMap((agent) => agent.skills ?? []));
|
|
1026
|
+
const skill = document.package.skills?.entries.find((candidate) => candidate.name === name && !referenced.has(candidate.name));
|
|
1027
|
+
if (!skill)
|
|
1028
|
+
return refusal(404, "no_skill", "That skill is not offered by this package.");
|
|
1029
|
+
if (this.deps.skills.list(botId).some((existing) => existing.name === name)) {
|
|
1030
|
+
return refusal(409, "skill_exists", `${bot.name} already has a skill named ${name}.`);
|
|
1031
|
+
}
|
|
1032
|
+
const added = this.deps.skills.installOrg(botId, `org:${install.ref}@${install.release}`, skill.instructions, { installId, key: skill.name, release: install.release, ...pair(skill.instructions, skill.instructions) });
|
|
1033
|
+
if ("error" in added)
|
|
1034
|
+
return refusal(422, "skill_failed", `The skill could not be added: ${added.error}`);
|
|
1035
|
+
return { ok: true, status: 201, value: added };
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
function mostCommon(values) {
|
|
1039
|
+
const counts = new Map();
|
|
1040
|
+
for (const value of values)
|
|
1041
|
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
1042
|
+
let best;
|
|
1043
|
+
let bestCount = 0;
|
|
1044
|
+
for (const [value, total] of counts) {
|
|
1045
|
+
if (total > bestCount) {
|
|
1046
|
+
best = value;
|
|
1047
|
+
bestCount = total;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return best;
|
|
1051
|
+
}
|