cloving 1.1.0 → 1.2.0
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 +121 -21
- package/assets/watch-fonts/fira-code-OFL.md +93 -0
- package/assets/watch-fonts/fira-code-latin-400-normal.woff2 +0 -0
- package/assets/watch-fonts/fira-code-latin-500-normal.woff2 +0 -0
- package/assets/watch-fonts/heebo-OFL.md +93 -0
- package/assets/watch-fonts/heebo-latin-500-normal.woff2 +0 -0
- package/assets/watch-fonts/heebo-latin-700-normal.woff2 +0 -0
- package/assets/watch-fonts/heebo-latin-800-normal.woff2 +0 -0
- package/assets/watch-fonts/manifest.json +112 -0
- package/assets/watch-fonts/space-grotesk-OFL.md +93 -0
- package/assets/watch-fonts/space-grotesk-latin-400-normal.woff2 +0 -0
- package/assets/watch-fonts/space-grotesk-latin-500-normal.woff2 +0 -0
- package/assets/watch-fonts/space-grotesk-latin-600-normal.woff2 +0 -0
- package/assets/watch-fonts/space-grotesk-latin-700-normal.woff2 +0 -0
- package/dist/src/authority/commands.d.ts +1 -0
- package/dist/src/authority/commands.js +25 -4
- package/dist/src/authority/dispatch-decision.d.ts +1 -0
- package/dist/src/authority/dispatch-decision.js +7 -4
- package/dist/src/authority/events.js +2 -2
- package/dist/src/authority/handoff-chain.d.ts +24 -0
- package/dist/src/authority/handoff-chain.js +46 -0
- package/dist/src/authority/reconciliation.d.ts +1 -0
- package/dist/src/authority/reconciliation.js +18 -11
- package/dist/src/authority/transitions.d.ts +1 -1
- package/dist/src/authority/transitions.js +3 -0
- package/dist/src/broker/actor/authority-queries.d.ts +4 -0
- package/dist/src/broker/actor/authority-queries.js +51 -2
- package/dist/src/broker/actor/delivery-intents.d.ts +1 -0
- package/dist/src/broker/actor/effect-delivery.js +9 -0
- package/dist/src/broker/authority-admission.js +13 -5
- package/dist/src/chats/analysis-batches.d.ts +18 -0
- package/dist/src/chats/analysis-batches.js +15 -1
- package/dist/src/chats/analysis-checkpoint.d.ts +29 -0
- package/dist/src/chats/analysis-checkpoint.js +31 -0
- package/dist/src/chats/analysis-input.d.ts +7 -1
- package/dist/src/chats/analysis-input.js +20 -6
- package/dist/src/chats/analysis-merge.d.ts +16 -0
- package/dist/src/chats/analysis-merge.js +134 -0
- package/dist/src/chats/analysis-prompt.d.ts +8 -0
- package/dist/src/chats/analysis-prompt.js +27 -1
- package/dist/src/chats/analysis-proposal.d.ts +2 -0
- package/dist/src/chats/analysis-proposal.js +9 -0
- package/dist/src/chats/analysis-shape.d.ts +1 -0
- package/dist/src/chats/analysis-shape.js +11 -4
- package/dist/src/chats/reference-mapping.d.ts +16 -0
- package/dist/src/chats/reference-mapping.js +153 -0
- package/dist/src/chats/workflow-generation-projection.d.ts +8 -0
- package/dist/src/chats/workflow-generation-projection.js +75 -0
- package/dist/src/chats/workflow-generation-source.d.ts +25 -0
- package/dist/src/chats/workflow-generation-source.js +124 -0
- package/dist/src/chats/workflow-generation-text.d.ts +23 -0
- package/dist/src/chats/workflow-generation-text.js +74 -0
- package/dist/src/chats/workflow-generation.d.ts +69 -0
- package/dist/src/chats/workflow-generation.js +142 -0
- package/dist/src/cli/render.d.ts +1 -0
- package/dist/src/cli/render.js +60 -13
- package/dist/src/cli/usage.js +5 -2
- package/dist/src/cli/watch-board-view.d.ts +12 -1
- package/dist/src/cli/watch-board-view.js +57 -11
- package/dist/src/cli/watch-board.d.ts +7 -1
- package/dist/src/cli/watch-board.js +42 -10
- package/dist/src/cli/watch-client.d.ts +1 -1
- package/dist/src/cli/watch-client.js +7 -3
- package/dist/src/cli/watch-model.d.ts +10 -4
- package/dist/src/cli/watch-model.js +35 -58
- package/dist/src/cli/watch-page.js +21 -4
- package/dist/src/cli/watch-phases.js +3 -3
- package/dist/src/cli/watch-pr-command.d.ts +5 -2
- package/dist/src/cli/watch-pr-command.js +41 -4
- package/dist/src/cli/watch-pr-identity.d.ts +14 -2
- package/dist/src/cli/watch-pr-identity.js +25 -0
- package/dist/src/cli/watch-pr-observer.d.ts +1 -0
- package/dist/src/cli/watch-pr-observer.js +48 -6
- package/dist/src/cli/watch-pull-request.d.ts +10 -0
- package/dist/src/cli/watch-pull-request.js +130 -0
- package/dist/src/cli/watch-reads.d.ts +7 -1
- package/dist/src/cli/watch-reads.js +42 -20
- package/dist/src/cli/watch-render.d.ts +14 -1
- package/dist/src/cli/watch-render.js +2 -1
- package/dist/src/cli/watch-server.js +33 -2
- package/dist/src/cli/watch-turn.js +1 -1
- package/dist/src/cli/watch-view.d.ts +7 -0
- package/dist/src/cli/watch-view.js +51 -3
- package/dist/src/daemon/analysis-worker.d.ts +0 -2
- package/dist/src/daemon/analysis-worker.js +71 -19
- package/dist/src/daemon/chats-batch-binding.d.ts +23 -0
- package/dist/src/daemon/chats-batch-binding.js +99 -0
- package/dist/src/daemon/chats-capture.d.ts +18 -0
- package/dist/src/daemon/chats-capture.js +194 -0
- package/dist/src/daemon/chats-checkpoints.d.ts +41 -0
- package/dist/src/daemon/chats-checkpoints.js +186 -0
- package/dist/src/daemon/chats-files.d.ts +29 -2
- package/dist/src/daemon/chats-files.js +142 -3
- package/dist/src/daemon/chats-follow-up.d.ts +38 -0
- package/dist/src/daemon/chats-follow-up.js +180 -0
- package/dist/src/daemon/chats-question.d.ts +15 -0
- package/dist/src/daemon/chats-question.js +93 -0
- package/dist/src/daemon/chats-worker.d.ts +1 -0
- package/dist/src/daemon/chats-worker.js +52 -51
- package/dist/src/daemon/claude-turn.d.ts +2 -1
- package/dist/src/daemon/claude-turn.js +1 -1
- package/dist/src/daemon/claude-worker.d.ts +0 -2
- package/dist/src/daemon/claude-worker.js +6 -11
- package/dist/src/daemon/daemon.d.ts +5 -1
- package/dist/src/daemon/daemon.js +38 -10
- package/dist/src/daemon/delivery-router.js +6 -0
- package/dist/src/daemon/github-calls.d.ts +4 -0
- package/dist/src/daemon/github-calls.js +16 -7
- package/dist/src/daemon/github-ci.d.ts +6 -0
- package/dist/src/daemon/github-ci.js +170 -0
- package/dist/src/daemon/github-merge-call.d.ts +14 -0
- package/dist/src/daemon/github-merge-call.js +73 -0
- package/dist/src/daemon/github-merge.d.ts +3 -5
- package/dist/src/daemon/github-merge.js +52 -95
- package/dist/src/daemon/github-methods.d.ts +28 -0
- package/dist/src/daemon/github-methods.js +80 -16
- package/dist/src/daemon/github-operation.d.ts +2 -0
- package/dist/src/daemon/github-retry.d.ts +6 -0
- package/dist/src/daemon/github-retry.js +49 -0
- package/dist/src/daemon/github-ticket-status.js +27 -1
- package/dist/src/daemon/github-worker.js +2 -1
- package/dist/src/daemon/handoff-worker.d.ts +10 -0
- package/dist/src/daemon/handoff-worker.js +232 -0
- package/dist/src/daemon/issue-queue.d.ts +1 -0
- package/dist/src/daemon/issue-queue.js +94 -42
- package/dist/src/daemon/notify-journal.d.ts +18 -0
- package/dist/src/daemon/notify-journal.js +108 -0
- package/dist/src/daemon/notify-message.js +2 -1
- package/dist/src/daemon/notify.js +176 -37
- package/dist/src/daemon/retrying-turn.d.ts +3 -0
- package/dist/src/daemon/retrying-turn.js +55 -9
- package/dist/src/daemon/ticket-dependency-waits.d.ts +18 -0
- package/dist/src/daemon/ticket-dependency-waits.js +60 -0
- package/dist/src/daemon/ticket-registration.d.ts +8 -4
- package/dist/src/daemon/ticket-registration.js +76 -22
- package/dist/src/daemon/ticket-source.d.ts +4 -1
- package/dist/src/daemon/ticket-source.js +8 -2
- package/dist/src/decision-visibility.d.ts +2 -0
- package/dist/src/decision-visibility.js +10 -0
- package/dist/src/execution-pin.d.ts +4 -0
- package/dist/src/execution-pin.js +23 -2
- package/dist/src/host/start-delivery.d.ts +2 -1
- package/dist/src/host/start-delivery.js +1 -1
- package/dist/src/host/step-bridge-host.d.ts +1 -0
- package/dist/src/host/step-bridge-host.js +16 -0
- package/dist/src/host/world-reconciliation.d.ts +1 -0
- package/dist/src/host/world-reconciliation.js +3 -0
- package/dist/src/host/world-runs.js +5 -0
- package/dist/src/issue-identity.d.ts +6 -0
- package/dist/src/issue-identity.js +23 -0
- package/dist/src/service/queue-runs.d.ts +3 -0
- package/dist/src/service/queue-runs.js +39 -0
- package/dist/src/service/registry.d.ts +8 -2
- package/dist/src/service/registry.js +20 -8
- package/dist/src/service/replies.d.ts +2 -0
- package/dist/src/service/run-handlers.js +44 -12
- package/dist/src/service/service.d.ts +3 -0
- package/dist/src/service/service.js +3 -1
- package/dist/src/service/submission-handler.d.ts +1 -0
- package/dist/src/service/submission-handler.js +92 -13
- package/dist/src/service/worker-totals.js +25 -1
- package/dist/src/step-bridge.d.ts +9 -0
- package/dist/src/steps/handoff.d.ts +2 -0
- package/dist/src/steps/handoff.js +13 -0
- package/dist/src/ticket-dependencies.d.ts +1 -0
- package/dist/src/ticket-dependencies.js +15 -0
- package/dist/src/ticket-status.js +7 -4
- package/dist/src/token-prices.d.ts +2 -0
- package/dist/src/token-prices.js +41 -9
- package/dist/src/worker-usage.d.ts +9 -0
- package/dist/src/worker-usage.js +1 -1
- package/dist/src/workflow-admission.d.ts +1 -0
- package/dist/src/workflow-candidate.js +1 -0
- package/dist/src/workflow-contracts.d.ts +2 -0
- package/dist/src/workflow-contracts.js +8 -3
- package/dist/src/workflow-declaration.d.ts +11 -0
- package/dist/src/workflow-declaration.js +71 -15
- package/dist/src/workflow-discovery.d.ts +18 -0
- package/dist/src/workflow-discovery.js +33 -0
- package/dist/src/workflow-refusals.d.ts +6 -0
- package/dist/src/workflow-refusals.js +39 -0
- package/dist/src/workflows.d.ts +2 -1
- package/dist/src/workflows.js +1 -0
- package/dist/tools/admit-workflow.d.ts +13 -0
- package/dist/tools/admit-workflow.js +94 -0
- package/dist/tools/self-host-project.d.ts +17 -0
- package/dist/tools/self-host-project.js +177 -0
- package/dist/tools/self-host.js +27 -81
- package/dist/tools/workflow-bundle-dependencies.d.ts +5 -0
- package/dist/tools/workflow-bundle-dependencies.js +93 -0
- package/dist/tools/workflow-bundles.d.ts +20 -0
- package/dist/tools/workflow-bundles.js +173 -0
- package/dist/tools/workflow-candidate-counts.d.ts +8 -0
- package/dist/tools/workflow-candidate-counts.js +251 -0
- package/dist/tools/workflow-candidate-program.d.ts +11 -0
- package/dist/tools/workflow-candidate-program.js +78 -0
- package/dist/tools/workflow-candidate-source.d.ts +6 -0
- package/dist/tools/workflow-candidate-source.js +107 -0
- package/dist/tools/workflow-candidate-syntax.d.ts +15 -0
- package/dist/tools/workflow-candidate-syntax.js +219 -0
- package/dist/tools/workflow-folder-capture.d.ts +17 -0
- package/dist/tools/workflow-folder-capture.js +216 -0
- package/dist/tools/workflow-folders.d.ts +33 -0
- package/dist/tools/workflow-folders.js +150 -0
- package/dist/tools/workflow-source-effects.d.ts +10 -0
- package/dist/tools/workflow-source-effects.js +213 -0
- package/dist/workflows/analyze-chats.d.ts +6 -1
- package/dist/workflows/analyze-chats.declaration.json +14 -1
- package/dist/workflows/analyze-chats.js +188 -11
- package/dist/workflows/develop-prd.js +71 -3
- package/dist/workflows/develop-ticket.js +5 -0
- package/dist/workflows/ticket-prompts.js +12 -2
- package/package.json +21 -3
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { record } from "../protocol.js";
|
|
3
|
+
import { initialAnalysisCheckpoint, batchAnalysisCheckpoint, } from "../chats/analysis-checkpoint.js";
|
|
4
|
+
import { analysisEvidence, } from "../chats/analysis-input.js";
|
|
5
|
+
import { mergeAnalysisBatch } from "../chats/analysis-merge.js";
|
|
6
|
+
import { prepareAnalysisBatch } from "../chats/analysis-batches.js";
|
|
7
|
+
import { checkpointProposal } from "../chats/analysis-proposal.js";
|
|
8
|
+
import { findAnalysisCapture, readCapturedAnalysisSnapshot, } from "./chats-capture.js";
|
|
9
|
+
import { boundedAnalysisFile, inspectAnalysisFiles } from "./chats-files.js";
|
|
10
|
+
export async function boundAnalysisBatch(directory, input) {
|
|
11
|
+
const capture = await findAnalysisCapture(directory, {
|
|
12
|
+
runId: input.runId,
|
|
13
|
+
importId: input.importId,
|
|
14
|
+
});
|
|
15
|
+
if (capture === undefined)
|
|
16
|
+
throw new Error("analysis-output-failed");
|
|
17
|
+
const snapshot = await readCapturedAnalysisSnapshot(directory, capture);
|
|
18
|
+
const binding = record(input.batch);
|
|
19
|
+
const batchIndex = binding?.["batchIndex"] ?? 0;
|
|
20
|
+
if (typeof batchIndex !== "number")
|
|
21
|
+
throw new Error("analysis-output-failed");
|
|
22
|
+
const previous = await previousCheckpoint(join(directory, `capture-${capture.key}`), {
|
|
23
|
+
capture,
|
|
24
|
+
batchIndex,
|
|
25
|
+
snapshot,
|
|
26
|
+
});
|
|
27
|
+
const prepared = prepareAnalysisBatch(snapshot, {
|
|
28
|
+
nextRecordIndex: previous.nextRecordIndex,
|
|
29
|
+
completedBatchCount: previous.completedBatchCount,
|
|
30
|
+
previousResult: previous.result,
|
|
31
|
+
});
|
|
32
|
+
if (prepared.kind === "refused")
|
|
33
|
+
throw new Error("analysis-output-failed");
|
|
34
|
+
if (input.snapshot !== undefined &&
|
|
35
|
+
JSON.stringify(input.batch === undefined ? snapshot : prepared.snapshot) !==
|
|
36
|
+
JSON.stringify(input.snapshot))
|
|
37
|
+
throw new Error("analysis-output-failed");
|
|
38
|
+
if (!matchingBatchBinding(input.batch, {
|
|
39
|
+
capturedDigest: capture.digest,
|
|
40
|
+
batchIndex,
|
|
41
|
+
nextRecordIndex: prepared.nextRecordIndex,
|
|
42
|
+
totalRecords: capture.totalRecords,
|
|
43
|
+
}))
|
|
44
|
+
throw new Error("analysis-output-failed");
|
|
45
|
+
return {
|
|
46
|
+
capture,
|
|
47
|
+
snapshot,
|
|
48
|
+
batch: { batchIndex, nextRecordIndex: prepared.nextRecordIndex },
|
|
49
|
+
previous,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function matchingBatchBinding(binding, expected) {
|
|
53
|
+
if (binding === undefined)
|
|
54
|
+
return expected.nextRecordIndex === expected.totalRecords;
|
|
55
|
+
const fields = record(binding);
|
|
56
|
+
return ["capturedDigest", "batchIndex", "nextRecordIndex"].every((name) => fields?.[name] === Reflect.get(expected, name));
|
|
57
|
+
}
|
|
58
|
+
export async function previousCheckpoint(folder, input) {
|
|
59
|
+
const { capture, batchIndex } = input;
|
|
60
|
+
if (!Number.isSafeInteger(batchIndex) || batchIndex < 0 || batchIndex > 3)
|
|
61
|
+
throw new Error("analysis-input-refused");
|
|
62
|
+
const initial = initialAnalysisCheckpoint(capture);
|
|
63
|
+
if (batchIndex === 0)
|
|
64
|
+
return initial;
|
|
65
|
+
const name = `analysis-checkpoint-${String(batchIndex)}.json`;
|
|
66
|
+
const text = await boundedAnalysisFile(join(folder, name), {
|
|
67
|
+
permissions: 0o600,
|
|
68
|
+
});
|
|
69
|
+
const checkpoint = restoreCheckpoint(text, input);
|
|
70
|
+
await inspectAnalysisFiles(folder, {
|
|
71
|
+
cycleId: `checkpoint-${String(batchIndex)}`,
|
|
72
|
+
analysis: JSON.stringify(checkpoint) + "\n",
|
|
73
|
+
proposal: checkpointProposal(checkpoint),
|
|
74
|
+
});
|
|
75
|
+
return checkpoint;
|
|
76
|
+
}
|
|
77
|
+
function restoreCheckpoint(text, input) {
|
|
78
|
+
const value = record(JSON.parse(text));
|
|
79
|
+
const result = record(value?.["result"]);
|
|
80
|
+
const nextRecordIndex = value?.["nextRecordIndex"];
|
|
81
|
+
if (result === undefined || typeof nextRecordIndex !== "number")
|
|
82
|
+
throw new Error("analysis-output-failed");
|
|
83
|
+
const merged = mergeAnalysisBatch({
|
|
84
|
+
previousResult: null,
|
|
85
|
+
candidate: Object.fromEntries(Object.entries(result).filter(([key]) => key !== "limitations")),
|
|
86
|
+
evidence: analysisEvidence(input.snapshot),
|
|
87
|
+
nextRecordIndex,
|
|
88
|
+
});
|
|
89
|
+
if (!merged.accepted)
|
|
90
|
+
throw new Error("analysis-output-failed");
|
|
91
|
+
const checkpoint = batchAnalysisCheckpoint(input.capture, {
|
|
92
|
+
batchIndex: input.batchIndex - 1,
|
|
93
|
+
nextRecordIndex,
|
|
94
|
+
result: merged.result,
|
|
95
|
+
});
|
|
96
|
+
if (text !== JSON.stringify(checkpoint) + "\n")
|
|
97
|
+
throw new Error("analysis-output-failed");
|
|
98
|
+
return checkpoint;
|
|
99
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type AnalysisSnapshot } from "../chats/analysis-input.ts";
|
|
2
|
+
export interface AnalysisCapture {
|
|
3
|
+
readonly schemaVersion: 1;
|
|
4
|
+
readonly key: string;
|
|
5
|
+
readonly runId: string;
|
|
6
|
+
readonly importId: string;
|
|
7
|
+
readonly digest: string;
|
|
8
|
+
readonly totalRecords: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function captureAnalysisSnapshot(directory: string, binding: {
|
|
11
|
+
readonly runId: string;
|
|
12
|
+
readonly importId: string;
|
|
13
|
+
}): Promise<AnalysisCapture>;
|
|
14
|
+
export declare function findAnalysisCapture(directory: string, binding: {
|
|
15
|
+
readonly runId: string;
|
|
16
|
+
readonly importId: string;
|
|
17
|
+
}): Promise<AnalysisCapture | undefined>;
|
|
18
|
+
export declare function readCapturedAnalysisSnapshot(directory: string, capture: AnalysisCapture): Promise<AnalysisSnapshot>;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, mkdtemp, readdir, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { record } from "../protocol.js";
|
|
5
|
+
import { analysisEvidence, validAnalysisImportId, } from "../chats/analysis-input.js";
|
|
6
|
+
import { validateDestination } from "../chats/destination.js";
|
|
7
|
+
import { initialAnalysisCheckpoint } from "../chats/analysis-checkpoint.js";
|
|
8
|
+
import { checkpointProposal } from "../chats/analysis-proposal.js";
|
|
9
|
+
import { boundedAnalysisFile, matchingPrivateFile, readAnalysisCorpus, readAnalysisSnapshot, } from "./chats-files.js";
|
|
10
|
+
export async function captureAnalysisSnapshot(directory, binding) {
|
|
11
|
+
const key = captureKey(binding);
|
|
12
|
+
const folder = join(directory, `capture-${key}`);
|
|
13
|
+
await validateDestination(directory);
|
|
14
|
+
await reclaimAbandonedCaptures(directory);
|
|
15
|
+
const existing = await findAnalysisCapture(directory, binding);
|
|
16
|
+
if (existing !== undefined)
|
|
17
|
+
return existing;
|
|
18
|
+
const temporary = await mkdtemp(join(directory, `.capture-${key}-${String(process.pid)}-`));
|
|
19
|
+
try {
|
|
20
|
+
const capture = await writeCapture(directory, {
|
|
21
|
+
...binding,
|
|
22
|
+
folder: temporary,
|
|
23
|
+
});
|
|
24
|
+
try {
|
|
25
|
+
await rename(temporary, folder);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (!(error instanceof Error &&
|
|
29
|
+
"code" in error &&
|
|
30
|
+
(error.code === "EEXIST" || error.code === "ENOTEMPTY")))
|
|
31
|
+
throw error;
|
|
32
|
+
const published = await findAnalysisCapture(directory, binding);
|
|
33
|
+
if (published === undefined)
|
|
34
|
+
throw error;
|
|
35
|
+
return published;
|
|
36
|
+
}
|
|
37
|
+
return capture;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (missingRedaction(error))
|
|
41
|
+
throw new Error("redaction-required", { cause: error });
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await rm(temporary, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function missingRedaction(error) {
|
|
49
|
+
if (!(error instanceof Error) ||
|
|
50
|
+
!("code" in error) ||
|
|
51
|
+
error.code !== "ENOENT" ||
|
|
52
|
+
!("path" in error) ||
|
|
53
|
+
typeof error.path !== "string")
|
|
54
|
+
return false;
|
|
55
|
+
return (error.path.endsWith("/redaction.json") ||
|
|
56
|
+
error.path.endsWith("/redacted.jsonl"));
|
|
57
|
+
}
|
|
58
|
+
async function reclaimAbandonedCaptures(directory) {
|
|
59
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
60
|
+
if (entry.isDirectory() && absentCaptureOwner(entry.name))
|
|
61
|
+
await rm(join(directory, entry.name), { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function absentCaptureOwner(name) {
|
|
65
|
+
const owner = /^\.capture-[a-f0-9]{64}-([1-9][0-9]*)-[A-Za-z0-9]{6}$/.exec(name)?.[1];
|
|
66
|
+
if (owner === undefined)
|
|
67
|
+
return false;
|
|
68
|
+
const processId = Number(owner);
|
|
69
|
+
if (!Number.isSafeInteger(processId))
|
|
70
|
+
return false;
|
|
71
|
+
try {
|
|
72
|
+
process.kill(processId, 0);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
return error instanceof Error && "code" in error && error.code === "ESRCH";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export async function findAnalysisCapture(directory, binding) {
|
|
80
|
+
const folder = join(directory, `capture-${captureKey(binding)}`);
|
|
81
|
+
const stats = await lstat(folder).catch((error) => {
|
|
82
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
|
83
|
+
throw error;
|
|
84
|
+
return undefined;
|
|
85
|
+
});
|
|
86
|
+
if (stats === undefined)
|
|
87
|
+
return undefined;
|
|
88
|
+
const capture = await capturedReference(folder, binding);
|
|
89
|
+
await readCapturedAnalysisSnapshot(directory, capture);
|
|
90
|
+
return capture;
|
|
91
|
+
}
|
|
92
|
+
async function writeCapture(directory, input) {
|
|
93
|
+
const snapshot = await copiedSnapshot(directory, {
|
|
94
|
+
folder: input.folder,
|
|
95
|
+
importId: input.importId,
|
|
96
|
+
});
|
|
97
|
+
const evidence = analysisEvidence(snapshot);
|
|
98
|
+
const capture = {
|
|
99
|
+
schemaVersion: 1,
|
|
100
|
+
key: captureKey(input),
|
|
101
|
+
runId: input.runId,
|
|
102
|
+
importId: input.importId,
|
|
103
|
+
digest: snapshotDigest(snapshot),
|
|
104
|
+
totalRecords: evidence.records.length,
|
|
105
|
+
};
|
|
106
|
+
await matchingPrivateFile(join(input.folder, "capture.json"), JSON.stringify(capture) + "\n");
|
|
107
|
+
await ensureInitialCheckpoint(input.folder, capture);
|
|
108
|
+
return capture;
|
|
109
|
+
}
|
|
110
|
+
export async function readCapturedAnalysisSnapshot(directory, capture) {
|
|
111
|
+
const key = captureKey(capture);
|
|
112
|
+
if (capture.key !== key)
|
|
113
|
+
throw new Error("analysis-input-refused");
|
|
114
|
+
const folder = join(directory, `capture-${key}`);
|
|
115
|
+
const stored = await capturedReference(folder, capture);
|
|
116
|
+
if (stored.digest !== capture.digest ||
|
|
117
|
+
stored.totalRecords !== capture.totalRecords ||
|
|
118
|
+
record(capture)?.["schemaVersion"] !== 1)
|
|
119
|
+
throw new Error("analysis-input-refused");
|
|
120
|
+
const snapshot = await readAnalysisSnapshot(folder, {
|
|
121
|
+
importId: capture.importId,
|
|
122
|
+
permissions: 0o600,
|
|
123
|
+
});
|
|
124
|
+
const evidence = analysisEvidence(snapshot);
|
|
125
|
+
if (snapshotDigest(snapshot) !== capture.digest ||
|
|
126
|
+
evidence.records.length !== capture.totalRecords)
|
|
127
|
+
throw new Error("analysis-input-refused");
|
|
128
|
+
await ensureInitialCheckpoint(folder, capture);
|
|
129
|
+
return snapshot;
|
|
130
|
+
}
|
|
131
|
+
async function ensureInitialCheckpoint(folder, capture) {
|
|
132
|
+
const checkpoint = initialAnalysisCheckpoint(capture);
|
|
133
|
+
for (const [name, expected] of [
|
|
134
|
+
["checkpoint-0.json", JSON.stringify(checkpoint) + "\n"],
|
|
135
|
+
["proposal-0.txt", checkpointProposal(checkpoint)],
|
|
136
|
+
]) {
|
|
137
|
+
await matchingPrivateFile(join(folder, name), expected);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function captureKey(binding) {
|
|
141
|
+
if (!validAnalysisImportId(binding.importId) ||
|
|
142
|
+
!/^[A-Za-z0-9_-]{1,128}$/.test(binding.runId))
|
|
143
|
+
throw new Error("analysis-input-refused");
|
|
144
|
+
return createHash("sha256")
|
|
145
|
+
.update(JSON.stringify([binding.runId, binding.importId]))
|
|
146
|
+
.digest("hex");
|
|
147
|
+
}
|
|
148
|
+
function snapshotDigest(snapshot) {
|
|
149
|
+
return createHash("sha256").update(JSON.stringify(snapshot)).digest("hex");
|
|
150
|
+
}
|
|
151
|
+
async function copiedSnapshot(directory, capture) {
|
|
152
|
+
const redaction = await boundedAnalysisFile(join(directory, "redaction.json"));
|
|
153
|
+
await matchingPrivateFile(join(capture.folder, "redaction.json"), redaction);
|
|
154
|
+
const corpus = await readAnalysisCorpus(join(directory, "redacted.jsonl"), {
|
|
155
|
+
output: join(capture.folder, "redacted.jsonl"),
|
|
156
|
+
});
|
|
157
|
+
const structure = await boundedAnalysisFile(join(directory, "structure.json"));
|
|
158
|
+
await matchingPrivateFile(join(capture.folder, "structure.json"), structure);
|
|
159
|
+
return { importId: capture.importId, redaction, corpus, structure };
|
|
160
|
+
}
|
|
161
|
+
async function capturedReference(folder, binding) {
|
|
162
|
+
await validateDestination(folder);
|
|
163
|
+
if (((await lstat(folder)).mode & 0o777) !== 0o700)
|
|
164
|
+
throw new Error("analysis-output-failed");
|
|
165
|
+
const value = record(JSON.parse(await boundedAnalysisFile(join(folder, "capture.json"), {
|
|
166
|
+
permissions: 0o600,
|
|
167
|
+
})));
|
|
168
|
+
const identity = {
|
|
169
|
+
schemaVersion: 1,
|
|
170
|
+
key: captureKey(binding),
|
|
171
|
+
runId: binding.runId,
|
|
172
|
+
importId: binding.importId,
|
|
173
|
+
};
|
|
174
|
+
if (value === undefined ||
|
|
175
|
+
Object.keys(value).length !== 6 ||
|
|
176
|
+
!Object.entries(identity).every(([key, expected]) => value[key] === expected))
|
|
177
|
+
throw new Error("analysis-input-refused");
|
|
178
|
+
const digest = value["digest"];
|
|
179
|
+
const totalRecords = value["totalRecords"];
|
|
180
|
+
if (typeof digest !== "string" ||
|
|
181
|
+
!/^[a-f0-9]{64}$/.test(digest) ||
|
|
182
|
+
typeof totalRecords !== "number" ||
|
|
183
|
+
!Number.isSafeInteger(totalRecords) ||
|
|
184
|
+
totalRecords < 0)
|
|
185
|
+
throw new Error("analysis-input-refused");
|
|
186
|
+
return {
|
|
187
|
+
schemaVersion: 1,
|
|
188
|
+
key: identity.key,
|
|
189
|
+
runId: binding.runId,
|
|
190
|
+
importId: binding.importId,
|
|
191
|
+
digest,
|
|
192
|
+
totalRecords,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type AnalysisCheckpoint } from "../chats/analysis-checkpoint.ts";
|
|
2
|
+
import { type AnalysisSnapshot } from "../chats/analysis-input.ts";
|
|
3
|
+
import { type AnalysisBatchPreparation } from "../chats/analysis-batches.ts";
|
|
4
|
+
import { type AnalysisCapture } from "./chats-capture.ts";
|
|
5
|
+
import { type AnalysisDraftInput } from "./chats-files.ts";
|
|
6
|
+
interface CheckpointInput {
|
|
7
|
+
readonly capture: AnalysisCapture;
|
|
8
|
+
readonly candidate: unknown;
|
|
9
|
+
readonly batchIndex: number;
|
|
10
|
+
readonly nextRecordIndex: number;
|
|
11
|
+
readonly mode: "deliver" | "reconcile";
|
|
12
|
+
}
|
|
13
|
+
interface FinalizeInput {
|
|
14
|
+
readonly runId: string;
|
|
15
|
+
readonly importId: string;
|
|
16
|
+
readonly cycleId: string;
|
|
17
|
+
readonly snapshot: AnalysisSnapshot | undefined;
|
|
18
|
+
readonly candidate: unknown;
|
|
19
|
+
readonly mode: "deliver" | "reconcile";
|
|
20
|
+
readonly batch?: unknown;
|
|
21
|
+
readonly draft?: AnalysisDraftInput;
|
|
22
|
+
}
|
|
23
|
+
export declare function finalizeAnalysis(directory: string, input: FinalizeInput): Promise<(Awaited<ReturnType<typeof saveAnalysisCheckpoint>> & {
|
|
24
|
+
readonly next?: AnalysisBatchPreparation | undefined;
|
|
25
|
+
}) | {
|
|
26
|
+
readonly kind: "absent";
|
|
27
|
+
}>;
|
|
28
|
+
export declare function saveAnalysisCheckpoint(directory: string, input: CheckpointInput): Promise<{
|
|
29
|
+
kind: "committed" | "absent";
|
|
30
|
+
checkpoint: AnalysisCheckpoint & {
|
|
31
|
+
readonly result: import("../chats/analysis-contract.ts").AnalysisResult;
|
|
32
|
+
};
|
|
33
|
+
receipt: string;
|
|
34
|
+
} | {
|
|
35
|
+
kind: "refused";
|
|
36
|
+
codes: readonly import("../chats/analysis-merge.ts").AnalysisMergeCode[];
|
|
37
|
+
} | {
|
|
38
|
+
kind: "absent";
|
|
39
|
+
codes?: never;
|
|
40
|
+
}>;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { boundAnalysisBatch, previousCheckpoint, } from "./chats-batch-binding.js";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { batchAnalysisCheckpoint, } from "../chats/analysis-checkpoint.js";
|
|
5
|
+
import { analysisEvidence, analysisDocumentBytes, } from "../chats/analysis-input.js";
|
|
6
|
+
import { mergeAnalysisBatch } from "../chats/analysis-merge.js";
|
|
7
|
+
import { prepareAnalysisBatch, } from "../chats/analysis-batches.js";
|
|
8
|
+
import { analysisProposal, checkpointProposal, } from "../chats/analysis-proposal.js";
|
|
9
|
+
import { readCapturedAnalysisSnapshot, } from "./chats-capture.js";
|
|
10
|
+
import { boundedAnalysisFile, matchingPrivateFile, inspectAnalysisFiles, saveAnalysisFiles, } from "./chats-files.js";
|
|
11
|
+
export async function finalizeAnalysis(directory, input) {
|
|
12
|
+
const { capture, batch, snapshot } = await boundAnalysisBatch(directory, input);
|
|
13
|
+
const inspected = await saveAnalysisCheckpoint(directory, {
|
|
14
|
+
...batch,
|
|
15
|
+
capture,
|
|
16
|
+
candidate: input.candidate,
|
|
17
|
+
mode: "reconcile",
|
|
18
|
+
});
|
|
19
|
+
if (inspected.kind === "refused")
|
|
20
|
+
return inspected;
|
|
21
|
+
if (!("checkpoint" in inspected)) {
|
|
22
|
+
if (input.mode === "reconcile")
|
|
23
|
+
return { kind: "absent" };
|
|
24
|
+
return saveAnalysisCheckpoint(directory, {
|
|
25
|
+
...batch,
|
|
26
|
+
capture,
|
|
27
|
+
candidate: input.candidate,
|
|
28
|
+
mode: "deliver",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return commitAnalysisBatch(directory, {
|
|
32
|
+
input,
|
|
33
|
+
batch: { ...batch, capture, candidate: input.candidate, mode: input.mode },
|
|
34
|
+
snapshot,
|
|
35
|
+
inspected,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
async function commitAnalysisBatch(directory, prepared) {
|
|
39
|
+
const { input, batch, snapshot, inspected } = prepared;
|
|
40
|
+
const output = {
|
|
41
|
+
cycleId: input.cycleId,
|
|
42
|
+
analysis: JSON.stringify(inspected.checkpoint.result) + "\n",
|
|
43
|
+
proposal: analysisProposal(inspected.checkpoint.result),
|
|
44
|
+
...(input.draft === undefined ? {} : { draft: input.draft }),
|
|
45
|
+
};
|
|
46
|
+
const complete = inspected.checkpoint.status === "complete";
|
|
47
|
+
const legacy = complete
|
|
48
|
+
? await inspectAnalysisFiles(directory, output)
|
|
49
|
+
: "present";
|
|
50
|
+
if (input.mode === "reconcile" &&
|
|
51
|
+
(inspected.kind === "absent" || legacy === "absent"))
|
|
52
|
+
return { kind: "absent" };
|
|
53
|
+
const saved = await saveAnalysisCheckpoint(directory, {
|
|
54
|
+
...batch,
|
|
55
|
+
mode: input.mode,
|
|
56
|
+
});
|
|
57
|
+
if (saved.kind !== "committed")
|
|
58
|
+
return saved;
|
|
59
|
+
if (complete && input.mode === "deliver")
|
|
60
|
+
await saveAnalysisFiles(directory, output);
|
|
61
|
+
const next = complete
|
|
62
|
+
? undefined
|
|
63
|
+
: prepareAnalysisBatch(snapshot, {
|
|
64
|
+
nextRecordIndex: saved.checkpoint.nextRecordIndex,
|
|
65
|
+
completedBatchCount: saved.checkpoint.completedBatchCount,
|
|
66
|
+
previousResult: saved.checkpoint.result,
|
|
67
|
+
});
|
|
68
|
+
return { ...saved, next };
|
|
69
|
+
}
|
|
70
|
+
export async function saveAnalysisCheckpoint(directory, input) {
|
|
71
|
+
const { capture, candidate } = input;
|
|
72
|
+
const snapshot = await readCapturedAnalysisSnapshot(directory, capture);
|
|
73
|
+
const folder = join(directory, `capture-${capture.key}`);
|
|
74
|
+
const previous = await previousCheckpoint(folder, { ...input, snapshot });
|
|
75
|
+
if (!Number.isSafeInteger(input.nextRecordIndex) ||
|
|
76
|
+
input.nextRecordIndex <= previous.nextRecordIndex ||
|
|
77
|
+
input.nextRecordIndex > capture.totalRecords ||
|
|
78
|
+
previous.status === "complete")
|
|
79
|
+
throw new Error("analysis-input-refused");
|
|
80
|
+
const merged = mergeAnalysisBatch({
|
|
81
|
+
previousResult: previous.result,
|
|
82
|
+
candidate,
|
|
83
|
+
evidence: analysisEvidence(snapshot),
|
|
84
|
+
nextRecordIndex: input.nextRecordIndex,
|
|
85
|
+
});
|
|
86
|
+
if (!merged.accepted && !merged.codes.includes("analysis-merge-conflict"))
|
|
87
|
+
return { kind: "refused", codes: merged.codes };
|
|
88
|
+
if (!merged.accepted &&
|
|
89
|
+
(await retainConflict(folder, { ...input, previous })) === "absent")
|
|
90
|
+
return { kind: "absent" };
|
|
91
|
+
if (!merged.accepted)
|
|
92
|
+
return { kind: "refused", codes: merged.codes };
|
|
93
|
+
const checkpoint = batchAnalysisCheckpoint(capture, {
|
|
94
|
+
...input,
|
|
95
|
+
result: merged.result,
|
|
96
|
+
});
|
|
97
|
+
return persistCheckpoint(folder, { ...input, checkpoint });
|
|
98
|
+
}
|
|
99
|
+
async function bindBatch(folder, input) {
|
|
100
|
+
const { capture, batchIndex, nextRecordIndex, candidate, mode } = input;
|
|
101
|
+
const text = JSON.stringify({
|
|
102
|
+
schemaVersion: 1,
|
|
103
|
+
runId: capture.runId,
|
|
104
|
+
importId: capture.importId,
|
|
105
|
+
capturedDigest: capture.digest,
|
|
106
|
+
batchIndex,
|
|
107
|
+
nextRecordIndex,
|
|
108
|
+
candidateDigest: createHash("sha256")
|
|
109
|
+
.update(JSON.stringify(candidate))
|
|
110
|
+
.digest("hex"),
|
|
111
|
+
}) + "\n";
|
|
112
|
+
const path = join(folder, `batch-claim-${String(batchIndex)}.json`);
|
|
113
|
+
if (mode === "deliver")
|
|
114
|
+
await matchingPrivateFile(path, text);
|
|
115
|
+
else
|
|
116
|
+
await inspectMatchingPrivateFile(path, text);
|
|
117
|
+
}
|
|
118
|
+
async function persistCheckpoint(folder, input) {
|
|
119
|
+
const { checkpoint, mode } = input;
|
|
120
|
+
const output = {
|
|
121
|
+
cycleId: `checkpoint-${String(checkpoint.completedBatchCount)}`,
|
|
122
|
+
analysis: JSON.stringify(checkpoint) + "\n",
|
|
123
|
+
proposal: checkpointProposal(checkpoint),
|
|
124
|
+
};
|
|
125
|
+
const state = await inspectAnalysisFiles(folder, output);
|
|
126
|
+
await bindBatch(folder, input);
|
|
127
|
+
if (mode === "deliver")
|
|
128
|
+
await saveAnalysisFiles(folder, output);
|
|
129
|
+
return {
|
|
130
|
+
kind: mode === "reconcile" && state === "absent"
|
|
131
|
+
? "absent"
|
|
132
|
+
: "committed",
|
|
133
|
+
checkpoint,
|
|
134
|
+
receipt: createHash("sha256").update(output.analysis).digest("hex"),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async function retainConflict(folder, input) {
|
|
138
|
+
const { capture, candidate, mode, batchIndex, nextRecordIndex, previous } = input;
|
|
139
|
+
const incomingText = JSON.stringify(candidate) + "\n";
|
|
140
|
+
if (Buffer.byteLength(incomingText) > analysisDocumentBytes)
|
|
141
|
+
throw new Error("analysis-output-failed");
|
|
142
|
+
const incomingFile = `conflict-${String(batchIndex)}-incoming.json`;
|
|
143
|
+
const text = JSON.stringify({
|
|
144
|
+
schemaVersion: 1,
|
|
145
|
+
runId: capture.runId,
|
|
146
|
+
importId: capture.importId,
|
|
147
|
+
capturedDigest: capture.digest,
|
|
148
|
+
batchIndex,
|
|
149
|
+
nextRecordIndex,
|
|
150
|
+
accepted: {
|
|
151
|
+
file: `analysis-checkpoint-${String(previous.completedBatchCount)}.json`,
|
|
152
|
+
digest: createHash("sha256")
|
|
153
|
+
.update(JSON.stringify(previous) + "\n")
|
|
154
|
+
.digest("hex"),
|
|
155
|
+
},
|
|
156
|
+
incoming: {
|
|
157
|
+
file: incomingFile,
|
|
158
|
+
digest: createHash("sha256").update(incomingText).digest("hex"),
|
|
159
|
+
},
|
|
160
|
+
}) + "\n";
|
|
161
|
+
const path = join(folder, `conflict-${String(batchIndex)}.json`);
|
|
162
|
+
const incomingPath = join(folder, incomingFile);
|
|
163
|
+
await bindBatch(folder, input);
|
|
164
|
+
if (mode === "reconcile") {
|
|
165
|
+
const variant = await inspectMatchingPrivateFile(incomingPath, incomingText);
|
|
166
|
+
const envelope = await inspectMatchingPrivateFile(path, text);
|
|
167
|
+
return variant === "absent" || envelope === "absent"
|
|
168
|
+
? "absent"
|
|
169
|
+
: "retained";
|
|
170
|
+
}
|
|
171
|
+
await matchingPrivateFile(incomingPath, incomingText);
|
|
172
|
+
await matchingPrivateFile(path, text);
|
|
173
|
+
return "retained";
|
|
174
|
+
}
|
|
175
|
+
async function inspectMatchingPrivateFile(path, expected) {
|
|
176
|
+
const text = await boundedAnalysisFile(path, { permissions: 0o600 }).catch((error) => {
|
|
177
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
178
|
+
return undefined;
|
|
179
|
+
throw error;
|
|
180
|
+
});
|
|
181
|
+
if (text === undefined)
|
|
182
|
+
return "absent";
|
|
183
|
+
if (text !== expected)
|
|
184
|
+
throw new Error("analysis-output-failed");
|
|
185
|
+
return "retained";
|
|
186
|
+
}
|
|
@@ -1,15 +1,42 @@
|
|
|
1
1
|
import { type AnalysisSnapshot } from "../chats/analysis-input.ts";
|
|
2
|
+
export declare function readAnalysisCorpus(path: string, options?: {
|
|
3
|
+
readonly output?: string;
|
|
4
|
+
readonly permissions?: number;
|
|
5
|
+
}): Promise<string>;
|
|
2
6
|
export declare function analysisDirectory(runtime: string, importId: string): Promise<string>;
|
|
3
7
|
export declare function boundedAnalysisFile(path: string, options?: {
|
|
4
8
|
readonly permissions?: number;
|
|
5
9
|
}): Promise<string>;
|
|
6
|
-
export declare function readAnalysisSnapshot(directory: string,
|
|
10
|
+
export declare function readAnalysisSnapshot(directory: string, input: {
|
|
11
|
+
readonly importId: string;
|
|
12
|
+
readonly permissions?: number;
|
|
13
|
+
}): Promise<AnalysisSnapshot>;
|
|
7
14
|
export declare function matchingPrivateFile(path: string, text: string): Promise<void>;
|
|
8
|
-
interface AnalysisOutput {
|
|
15
|
+
export interface AnalysisOutput {
|
|
9
16
|
readonly cycleId: string;
|
|
10
17
|
readonly analysis: string;
|
|
11
18
|
readonly proposal: string;
|
|
19
|
+
readonly draft?: AnalysisDraftInput;
|
|
12
20
|
}
|
|
13
21
|
export declare function inspectAnalysisFiles(directory: string, output: AnalysisOutput): Promise<"complete" | "absent">;
|
|
14
22
|
export declare function saveAnalysisFiles(directory: string, output: AnalysisOutput): Promise<void>;
|
|
23
|
+
export interface AnalysisDraftInput {
|
|
24
|
+
readonly runId: string;
|
|
25
|
+
readonly cycleId: string;
|
|
26
|
+
readonly operationId: string;
|
|
27
|
+
readonly inputDigest: string;
|
|
28
|
+
readonly snapshotDigest: string;
|
|
29
|
+
readonly reply: string;
|
|
30
|
+
readonly batch?: unknown;
|
|
31
|
+
}
|
|
32
|
+
interface AnalysisDraftMetadata {
|
|
33
|
+
readonly candidateDigest: string;
|
|
34
|
+
readonly expiresAt: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function inspectAnalysisDraft(directory: string, input: AnalysisDraftInput): Promise<AnalysisDraftMetadata | undefined>;
|
|
37
|
+
export declare function inspectAnalysisDraftRecord(directory: string, input: AnalysisDraftInput): Promise<AnalysisDraftMetadata | undefined>;
|
|
38
|
+
export declare function saveAnalysisDraft(directory: string, saving: {
|
|
39
|
+
readonly input: AnalysisDraftInput;
|
|
40
|
+
readonly now: () => string;
|
|
41
|
+
}): Promise<AnalysisDraftMetadata>;
|
|
15
42
|
export {};
|