@openpond/evals 0.11.20 → 0.12.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 +12 -0
- package/dist/connected-evidence/codex-command-observations.js +81 -0
- package/dist/connected-evidence/collector-coverage-contracts.js +160 -0
- package/dist/connected-evidence/contracts.js +13 -0
- package/dist/connected-evidence/git-file-contracts.js +16 -0
- package/dist/connected-evidence/git-file-proof.js +86 -0
- package/dist/connected-evidence/git-starting-state.js +81 -0
- package/dist/connected-evidence/git.js +2 -0
- package/dist/connected-evidence/imports.js +2 -1
- package/dist/connected-evidence/index.js +2 -0
- package/dist/connected-evidence/normalize.js +1 -0
- package/dist/connected-evidence/sources/codex-metadata.js +33 -0
- package/dist/connected-evidence/sources/codex.js +16 -2
- package/dist/execution-contracts.js +1 -1
- package/dist/graders.js +10 -4
- package/dist/javascript-isolate-node.js +2 -1
- package/dist/javascript-isolate-process-source.js +1 -1
- package/dist/javascript-isolate-process.js +2 -1
- package/dist/javascript-isolate.js +2 -1
- package/dist/javascript-verifier-worker-source.js +1 -1
- package/dist/javascript-verifier.js +1 -0
- package/dist/native-conversations/codex-window-inventory.js +96 -0
- package/dist/native-conversations/collector-acquisition.js +149 -0
- package/dist/native-conversations/collector-branches.js +1 -0
- package/dist/native-conversations/collector-calendar-gate.js +19 -0
- package/dist/native-conversations/collector-contracts.js +4 -3
- package/dist/native-conversations/collector-coverage.js +100 -0
- package/dist/native-conversations/collector-cron.js +43 -0
- package/dist/native-conversations/collector-schedule.js +61 -0
- package/dist/native-conversations/collector-service-files.js +51 -0
- package/dist/native-conversations/collector-snapshots.js +70 -0
- package/dist/native-conversations/collector-source-cache.js +41 -0
- package/dist/native-conversations/collector-store.js +43 -8
- package/dist/native-conversations/collector.js +189 -261
- package/dist/native-conversations/git-assets.js +61 -0
- package/dist/native-conversations/history.js +9 -0
- package/dist/native-conversations/index.js +2 -0
- package/dist/native-conversations/location.js +4 -2
- package/dist/native-conversations/supervisor.js +131 -81
- package/dist/prepared-process-isolation.js +131 -0
- package/dist/rewards.js +17 -5
- package/dist/sql-execution-node.js +2 -1
- package/dist/tasksets.js +7 -1
- package/dist/types/builtin-benchmarks/harness-refiner.d.ts +4 -0
- package/dist/types/builtin-benchmarks/harness-refiner.d.ts.map +1 -1
- package/dist/types/conformance.d.ts +8 -0
- package/dist/types/conformance.d.ts.map +1 -1
- package/dist/types/connected-evidence/codex-command-observations.d.ts +15 -0
- package/dist/types/connected-evidence/codex-command-observations.d.ts.map +1 -0
- package/dist/types/connected-evidence/collector-coverage-contracts.d.ts +98 -0
- package/dist/types/connected-evidence/collector-coverage-contracts.d.ts.map +1 -0
- package/dist/types/connected-evidence/contracts.d.ts +22 -0
- package/dist/types/connected-evidence/contracts.d.ts.map +1 -1
- package/dist/types/connected-evidence/git-file-contracts.d.ts +18 -0
- package/dist/types/connected-evidence/git-file-contracts.d.ts.map +1 -0
- package/dist/types/connected-evidence/git-file-proof.d.ts +13 -0
- package/dist/types/connected-evidence/git-file-proof.d.ts.map +1 -0
- package/dist/types/connected-evidence/git-starting-state.d.ts +28 -0
- package/dist/types/connected-evidence/git-starting-state.d.ts.map +1 -0
- package/dist/types/connected-evidence/git.d.ts +3 -0
- package/dist/types/connected-evidence/git.d.ts.map +1 -0
- package/dist/types/connected-evidence/imports.d.ts.map +1 -1
- package/dist/types/connected-evidence/index.d.ts +2 -0
- package/dist/types/connected-evidence/index.d.ts.map +1 -1
- package/dist/types/connected-evidence/normalize.d.ts +1 -0
- package/dist/types/connected-evidence/normalize.d.ts.map +1 -1
- package/dist/types/connected-evidence/sources/claude-code.d.ts +10 -0
- package/dist/types/connected-evidence/sources/claude-code.d.ts.map +1 -1
- package/dist/types/connected-evidence/sources/codex-metadata.d.ts +10 -0
- package/dist/types/connected-evidence/sources/codex-metadata.d.ts.map +1 -0
- package/dist/types/connected-evidence/sources/codex.d.ts +10 -0
- package/dist/types/connected-evidence/sources/codex.d.ts.map +1 -1
- package/dist/types/connected-evidence/sources/grok-build.d.ts +10 -0
- package/dist/types/connected-evidence/sources/grok-build.d.ts.map +1 -1
- package/dist/types/connected-evidence/sources/history.d.ts +20 -0
- package/dist/types/connected-evidence/sources/history.d.ts.map +1 -1
- package/dist/types/connected-evidence/sources/opencode.d.ts +10 -0
- package/dist/types/connected-evidence/sources/opencode.d.ts.map +1 -1
- package/dist/types/connected-evidence/sources/pi.d.ts +10 -0
- package/dist/types/connected-evidence/sources/pi.d.ts.map +1 -1
- package/dist/types/execution-contracts.d.ts +8 -0
- package/dist/types/execution-contracts.d.ts.map +1 -1
- package/dist/types/graders.d.ts +3 -0
- package/dist/types/graders.d.ts.map +1 -1
- package/dist/types/javascript-isolate-node.d.ts.map +1 -1
- package/dist/types/javascript-isolate-process.d.ts.map +1 -1
- package/dist/types/javascript-isolate.d.ts.map +1 -1
- package/dist/types/javascript-verifier.d.ts +1 -0
- package/dist/types/javascript-verifier.d.ts.map +1 -1
- package/dist/types/learning/admission.d.ts +1 -0
- package/dist/types/learning/admission.d.ts.map +1 -1
- package/dist/types/learning/authoring-service.d.ts +2 -0
- package/dist/types/learning/authoring-service.d.ts.map +1 -1
- package/dist/types/learning/authoring.d.ts +8 -0
- package/dist/types/learning/authoring.d.ts.map +1 -1
- package/dist/types/learning/batch-service.d.ts +4 -0
- package/dist/types/learning/batch-service.d.ts.map +1 -1
- package/dist/types/learning/contracts.d.ts +17 -0
- package/dist/types/learning/contracts.d.ts.map +1 -1
- package/dist/types/learning/operations.d.ts +8 -0
- package/dist/types/learning/operations.d.ts.map +1 -1
- package/dist/types/learning/repository.d.ts +14 -0
- package/dist/types/learning/repository.d.ts.map +1 -1
- package/dist/types/learning/reward-calibration-closure.d.ts +9 -1
- package/dist/types/learning/reward-calibration-closure.d.ts.map +1 -1
- package/dist/types/learning/reward-checks.d.ts +2 -0
- package/dist/types/learning/reward-checks.d.ts.map +1 -1
- package/dist/types/learning/transport.d.ts +18 -0
- package/dist/types/learning/transport.d.ts.map +1 -1
- package/dist/types/native-conversations/codex-window-inventory.d.ts +12 -0
- package/dist/types/native-conversations/codex-window-inventory.d.ts.map +1 -0
- package/dist/types/native-conversations/collector-acquisition.d.ts +17 -0
- package/dist/types/native-conversations/collector-acquisition.d.ts.map +1 -0
- package/dist/types/native-conversations/collector-branches.d.ts.map +1 -1
- package/dist/types/native-conversations/collector-calendar-gate.d.ts +9 -0
- package/dist/types/native-conversations/collector-calendar-gate.d.ts.map +1 -0
- package/dist/types/native-conversations/collector-contracts.d.ts +41 -5
- package/dist/types/native-conversations/collector-contracts.d.ts.map +1 -1
- package/dist/types/native-conversations/collector-coverage.d.ts +62 -0
- package/dist/types/native-conversations/collector-coverage.d.ts.map +1 -0
- package/dist/types/native-conversations/collector-cron.d.ts +20 -0
- package/dist/types/native-conversations/collector-cron.d.ts.map +1 -0
- package/dist/types/native-conversations/collector-schedule.d.ts +13 -0
- package/dist/types/native-conversations/collector-schedule.d.ts.map +1 -0
- package/dist/types/native-conversations/collector-service-files.d.ts +20 -0
- package/dist/types/native-conversations/collector-service-files.d.ts.map +1 -0
- package/dist/types/native-conversations/collector-snapshots.d.ts +25 -0
- package/dist/types/native-conversations/collector-snapshots.d.ts.map +1 -0
- package/dist/types/native-conversations/collector-source-cache.d.ts +31 -0
- package/dist/types/native-conversations/collector-source-cache.d.ts.map +1 -0
- package/dist/types/native-conversations/collector-store.d.ts +11 -2
- package/dist/types/native-conversations/collector-store.d.ts.map +1 -1
- package/dist/types/native-conversations/collector.d.ts +5 -1
- package/dist/types/native-conversations/collector.d.ts.map +1 -1
- package/dist/types/native-conversations/git-assets.d.ts +32 -0
- package/dist/types/native-conversations/git-assets.d.ts.map +1 -0
- package/dist/types/native-conversations/history.d.ts +7 -0
- package/dist/types/native-conversations/history.d.ts.map +1 -1
- package/dist/types/native-conversations/index.d.ts +2 -0
- package/dist/types/native-conversations/index.d.ts.map +1 -1
- package/dist/types/native-conversations/location.d.ts.map +1 -1
- package/dist/types/native-conversations/supervisor.d.ts +4 -3
- package/dist/types/native-conversations/supervisor.d.ts.map +1 -1
- package/dist/types/prepared-process-isolation.d.ts +5 -0
- package/dist/types/prepared-process-isolation.d.ts.map +1 -0
- package/dist/types/review-conformance.d.ts +5 -5
- package/dist/types/rewards.d.ts +9 -0
- package/dist/types/rewards.d.ts.map +1 -1
- package/dist/types/sql-execution-node.d.ts.map +1 -1
- package/dist/types/tasksets.d.ts +16 -0
- package/dist/types/tasksets.d.ts.map +1 -1
- package/package.json +8 -2
- package/schemas/learning/v1/binding.schema.json +8 -0
- package/schemas/learning/v1/command-request.schema.json +8 -0
- package/schemas/learning/v1/decision.schema.json +5 -1
- package/schemas/learning/v1/draft.schema.json +8 -0
- package/schemas/learning/v1/grade.schema.json +5 -1
- package/schemas/learning/v1/package.schema.json +113 -94
- package/schemas/learning/v1/reward_check.schema.json +2 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { executeJavaScriptIsolate } from "./javascript-isolate.js";
|
|
2
2
|
import { assertIsolatedVerifierRuntime, JavaScriptVerifierResultSchema } from "./javascript-verifier-contract.js";
|
|
3
3
|
export { JavaScriptVerifierResultSchema } from "./javascript-verifier-contract.js";
|
|
4
|
+
export { PREPARED_PROCESS_ISOLATION_SOURCE } from "./prepared-process-isolation.js";
|
|
4
5
|
/** A fresh interpreter with no filesystem, network, timers or module loader.
|
|
5
6
|
* Hosts should use the worker entrypoint for cancellation and responsiveness. */
|
|
6
7
|
export async function executeJavaScriptVerifier(input) {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { CONNECTED_EVIDENCE_LIMITS } from "../connected-evidence/contracts.js";
|
|
4
|
+
import { CodexMetadataChain } from "../connected-evidence/sources/codex-metadata.js";
|
|
5
|
+
/** Streaming inventory may prove an irrelevant source without retaining it.
|
|
6
|
+
* It never truncates a selected conversation into an admissible snapshot. */
|
|
7
|
+
export async function inspectCodexWindow(input) {
|
|
8
|
+
const handle = await open(input.path, "r");
|
|
9
|
+
try {
|
|
10
|
+
const initial = await handle.stat();
|
|
11
|
+
// This is an inventory I/O bound, independent of the smaller upload bound.
|
|
12
|
+
if (!initial.isFile() || initial.size > 512 * 1024 * 1024)
|
|
13
|
+
return null;
|
|
14
|
+
const digest = createHash("sha256");
|
|
15
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
16
|
+
const from = input.since === null ? -Infinity : Date.parse(input.since);
|
|
17
|
+
const to = Date.parse(input.until);
|
|
18
|
+
const metadata = new CodexMetadataChain();
|
|
19
|
+
let records = 0, bytes = 0;
|
|
20
|
+
let pending = [], pendingBytes = 0;
|
|
21
|
+
const inspect = (line) => {
|
|
22
|
+
if (++records > 1_000_000 || line.byteLength > CONNECTED_EVIDENCE_LIMITS.lineBytes)
|
|
23
|
+
return false;
|
|
24
|
+
let text;
|
|
25
|
+
try {
|
|
26
|
+
text = decoder.decode(line);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (!text.trim())
|
|
32
|
+
return true;
|
|
33
|
+
let row;
|
|
34
|
+
try {
|
|
35
|
+
row = JSON.parse(text);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (!row || typeof row !== "object" || Array.isArray(row))
|
|
41
|
+
return false;
|
|
42
|
+
const value = row;
|
|
43
|
+
const payload = value.payload && typeof value.payload === "object" && !Array.isArray(value.payload)
|
|
44
|
+
? value.payload : {};
|
|
45
|
+
if (!metadata.accept(value) || (metadata.metadata && metadata.metadata.id !== input.sessionId))
|
|
46
|
+
return false;
|
|
47
|
+
// The Codex normalizer maps other native records to unknown events;
|
|
48
|
+
// they cannot create request boundaries. Missing metadata is still refused.
|
|
49
|
+
const request = value.type === "response_item" && payload.type === "message" && payload.role === "user"
|
|
50
|
+
|| value.type === "event_msg" && payload.type === "user_message";
|
|
51
|
+
if (request) {
|
|
52
|
+
const timestamp = typeof value.timestamp === "string" ? Date.parse(value.timestamp) : NaN;
|
|
53
|
+
if (!Number.isFinite(timestamp) || timestamp >= from && timestamp < to)
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
};
|
|
58
|
+
for await (const chunk of handle.createReadStream({ autoClose: false, highWaterMark: 256 * 1024 })) {
|
|
59
|
+
await input.check();
|
|
60
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
61
|
+
bytes += buffer.byteLength;
|
|
62
|
+
if (bytes > 512 * 1024 * 1024)
|
|
63
|
+
return null;
|
|
64
|
+
digest.update(buffer);
|
|
65
|
+
let start = 0, end;
|
|
66
|
+
while ((end = buffer.indexOf(10, start)) !== -1) {
|
|
67
|
+
const part = buffer.subarray(start, end);
|
|
68
|
+
if (pendingBytes + part.byteLength > CONNECTED_EVIDENCE_LIMITS.lineBytes)
|
|
69
|
+
return null;
|
|
70
|
+
const line = pending.length ? Buffer.concat([...pending, part], pendingBytes + part.byteLength) : part;
|
|
71
|
+
if (!inspect(line))
|
|
72
|
+
return null;
|
|
73
|
+
pending = [];
|
|
74
|
+
pendingBytes = 0;
|
|
75
|
+
start = end + 1;
|
|
76
|
+
}
|
|
77
|
+
const tail = buffer.subarray(start);
|
|
78
|
+
if (tail.byteLength) {
|
|
79
|
+
pending.push(tail);
|
|
80
|
+
pendingBytes += tail.byteLength;
|
|
81
|
+
}
|
|
82
|
+
if (pendingBytes > CONNECTED_EVIDENCE_LIMITS.lineBytes)
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (pendingBytes && !inspect(Buffer.concat(pending, pendingBytes)))
|
|
86
|
+
return null;
|
|
87
|
+
const final = await handle.stat();
|
|
88
|
+
if (initial.dev !== final.dev || initial.ino !== final.ino || initial.size !== final.size
|
|
89
|
+
|| initial.mtimeMs !== final.mtimeMs || initial.ctimeMs !== final.ctimeMs || bytes !== final.size)
|
|
90
|
+
throw new Error("Native source changed during window inventory.");
|
|
91
|
+
return metadata.metadata ? { sha256: digest.digest("hex") } : null;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await handle.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { contentHash } from "@openpond/harness";
|
|
2
|
+
import { agentImportNormalizerRevision } from "../connected-evidence/imports.js";
|
|
3
|
+
import { listSessions, readSession, inspectSessionWindow } from "./history.js";
|
|
4
|
+
import { NativeSessionNotReadyError } from "./readiness.js";
|
|
5
|
+
import { collectorBranchAnchor } from "./collector-branches.js";
|
|
6
|
+
import { CollectorQueueFullError } from "./collector-snapshots.js";
|
|
7
|
+
import { COLLECTOR_DEFAULTS } from "./collector-contracts.js";
|
|
8
|
+
import { CollectorCoverage } from "./collector-coverage.js";
|
|
9
|
+
import { CollectorSourceCache } from "./collector-source-cache.js";
|
|
10
|
+
/** One pass over a frozen listing per page. Unchanged revisions are cheap; a
|
|
11
|
+
* failed source is tried again only in a later explicitly launched job. */
|
|
12
|
+
export async function acquireCollectorSource(input) {
|
|
13
|
+
const { store, connection, check, drain, progress, until } = input;
|
|
14
|
+
const cache = new CollectorSourceCache(store);
|
|
15
|
+
let cursor, processed = 0, discovered = 0;
|
|
16
|
+
const failures = [];
|
|
17
|
+
do {
|
|
18
|
+
await check();
|
|
19
|
+
progress({ phase: "discovering" });
|
|
20
|
+
// File mtimes and native activity summaries are not event-time authority.
|
|
21
|
+
// Enumerate the source, then apply the consented window to native events.
|
|
22
|
+
const page = await listSessions(connection.source, { cursor, limit: 50 });
|
|
23
|
+
discovered += page.items.length;
|
|
24
|
+
if (discovered > 10_000)
|
|
25
|
+
throw new Error("Source inventory exceeds 10,000 sessions. Select a narrower import window.");
|
|
26
|
+
for (const native of page.items) {
|
|
27
|
+
const scanKey = JSON.stringify([native.path, native.nativeSessionId]);
|
|
28
|
+
store.coverage.record(connection.id, { sourceKeyHash: CollectorCoverage.sourceKey(scanKey),
|
|
29
|
+
nativeRevisionHash: native.storageRevision ? contentHash([native.storageRevision, agentImportNormalizerRevision(connection.source.source)]) : null,
|
|
30
|
+
state: "reading", reason: null, unknownTimeBoundaries: 0, normalized: [] });
|
|
31
|
+
}
|
|
32
|
+
progress({ discovered });
|
|
33
|
+
for (const native of page.items) {
|
|
34
|
+
await check();
|
|
35
|
+
// Do not parse another transcript while any retained admission is blocked.
|
|
36
|
+
await drain();
|
|
37
|
+
const queuedBytes = Number(store.database.prepare("SELECT COALESCE(SUM(bytes),0) AS n FROM pending").get()?.n) + store.snapshots.bytes();
|
|
38
|
+
if (queuedBytes >= COLLECTOR_DEFAULTS.maxBytes)
|
|
39
|
+
throw new CollectorQueueFullError();
|
|
40
|
+
progress({ phase: "reading" });
|
|
41
|
+
const scanKey = JSON.stringify([native.path, native.nativeSessionId]);
|
|
42
|
+
const scanRevision = native.storageRevision
|
|
43
|
+
? contentHash([native.storageRevision, agentImportNormalizerRevision(connection.source.source)]) : null;
|
|
44
|
+
store.progress.select(connection.id, scanKey);
|
|
45
|
+
const cached = scanRevision ? cache.read(connection, until, scanKey, scanRevision) : null;
|
|
46
|
+
if (cached) {
|
|
47
|
+
store.coverage.record(connection.id, cached);
|
|
48
|
+
store.progress.read(connection.id, scanKey, cached.state === "admitted");
|
|
49
|
+
progress({ processed: ++processed });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const coverage = { sourceKeyHash: CollectorCoverage.sourceKey(scanKey), nativeRevisionHash: scanRevision,
|
|
53
|
+
state: "reading", reason: scanRevision ? null : "source_revision_unavailable", unknownTimeBoundaries: 0, normalized: [] };
|
|
54
|
+
try {
|
|
55
|
+
const outside = await inspectSessionWindow(connection.source, native, { since: connection.since, until, check });
|
|
56
|
+
if (outside) {
|
|
57
|
+
coverage.nativeRevisionHash = contentHash(["codex-window-inventory-v1", outside.sha256]);
|
|
58
|
+
coverage.state = "skipped";
|
|
59
|
+
coverage.reason = "no_eligible_boundaries";
|
|
60
|
+
store.coverage.record(connection.id, coverage);
|
|
61
|
+
store.progress.read(connection.id, scanKey, false);
|
|
62
|
+
if (scanRevision) {
|
|
63
|
+
store.scanned(connection.id, scanKey, scanRevision);
|
|
64
|
+
cache.retain(connection, until, scanKey, scanRevision, coverage);
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const { files, preview, branchLeafId } = await readSession(connection.source, native, {
|
|
69
|
+
branchAnchor: collectorBranchAnchor(store, connection.id, native.nativeSessionId),
|
|
70
|
+
});
|
|
71
|
+
coverage.nativeRevisionHash = contentHash([files, agentImportNormalizerRevision(connection.source.source)]);
|
|
72
|
+
await check();
|
|
73
|
+
const snapshot = store.snapshots.prepare(files);
|
|
74
|
+
let eligible = false;
|
|
75
|
+
for (const session of preview.sessions) {
|
|
76
|
+
const key = JSON.stringify([connection.source.instanceId, session.sessionId, session.branchId]);
|
|
77
|
+
store.progress.retained(connection.id, scanKey, key);
|
|
78
|
+
const boundaries = session.boundaries.filter(boundary => {
|
|
79
|
+
if (boundary.projection !== "turn" || boundary.terminal === "unknown")
|
|
80
|
+
return false;
|
|
81
|
+
const timestamp = session.events[boundary.start]?.occurredAt;
|
|
82
|
+
if (!timestamp || !Number.isFinite(Date.parse(timestamp))) {
|
|
83
|
+
coverage.unknownTimeBoundaries++;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return Date.parse(timestamp) < Date.parse(until) && (!connection.since || Date.parse(timestamp) >= Date.parse(connection.since));
|
|
87
|
+
});
|
|
88
|
+
coverage.normalized.push({ sessionHash: session.contentHash, boundaries: boundaries.map(boundary => ({
|
|
89
|
+
id: boundary.id, inputHash: boundary.inputHash, revisionHash: boundary.revisionHash, occurredAt: session.events[boundary.start].occurredAt,
|
|
90
|
+
})).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0) });
|
|
91
|
+
store.coverage.record(connection.id, coverage);
|
|
92
|
+
eligible ||= boundaries.length > 0;
|
|
93
|
+
const changed = boundaries.filter(boundary => store.changed(connection.id, key, boundary.id, contentHash([boundary.inputHash, boundary.revisionHash])));
|
|
94
|
+
for (let offset = 0; offset < changed.length; offset += COLLECTOR_DEFAULTS.batchCases) {
|
|
95
|
+
await check();
|
|
96
|
+
const selected = changed.slice(offset, offset + COLLECTOR_DEFAULTS.batchCases);
|
|
97
|
+
const operationId = `sync-${contentHash([connection.id, key, selected.map(boundary => [boundary.id, boundary.inputHash, boundary.revisionHash])]).slice(0, 48)}`;
|
|
98
|
+
store.enqueue({ operationId, connectionId: connection.id, sessionKey: key, contentHash: session.contentHash,
|
|
99
|
+
files, boundaryIds: selected.map(boundary => boundary.id), ...(branchLeafId ? { branchLeafId } : {}) }, selected.map(boundary => ({ id: boundary.id, revision: contentHash([boundary.inputHash, boundary.revisionHash]) })), scanKey, snapshot);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
store.progress.read(connection.id, scanKey, eligible);
|
|
103
|
+
if (scanRevision)
|
|
104
|
+
store.scanned(connection.id, scanKey, scanRevision);
|
|
105
|
+
await drain();
|
|
106
|
+
coverage.state = eligible ? "admitted" : "skipped";
|
|
107
|
+
coverage.reason = eligible ? null : "no_eligible_boundaries";
|
|
108
|
+
coverage.normalized.sort((a, b) => a.sessionHash < b.sessionHash ? -1 : a.sessionHash > b.sessionHash ? 1 : 0);
|
|
109
|
+
store.coverage.record(connection.id, coverage);
|
|
110
|
+
if (scanRevision)
|
|
111
|
+
cache.retain(connection, until, scanKey, scanRevision, coverage);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error instanceof NativeSessionNotReadyError) {
|
|
115
|
+
coverage.state = "skipped";
|
|
116
|
+
coverage.reason = "native_session_not_ready";
|
|
117
|
+
store.coverage.record(connection.id, coverage);
|
|
118
|
+
store.progress.read(connection.id, scanKey, false);
|
|
119
|
+
if (scanRevision)
|
|
120
|
+
store.scanned(connection.id, scanKey, scanRevision);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// Capacity/lifecycle failures end this run instead of retrying all files.
|
|
124
|
+
if (error instanceof CollectorQueueFullError || error instanceof CollectorInterruptedError || error instanceof CollectorScopeChangedError)
|
|
125
|
+
throw error;
|
|
126
|
+
const message = error instanceof Error ? error.message : "Unable to read session";
|
|
127
|
+
coverage.state = "failed";
|
|
128
|
+
coverage.reason = "source_read_failed";
|
|
129
|
+
store.coverage.record(connection.id, coverage);
|
|
130
|
+
store.progress.failed(connection.id, scanKey, message);
|
|
131
|
+
failures.push(`${native.nativeSessionId}: ${message}`);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
progress({ processed: ++processed });
|
|
135
|
+
}
|
|
136
|
+
// Admit one source snapshot before moving on to the next session.
|
|
137
|
+
await drain();
|
|
138
|
+
}
|
|
139
|
+
cursor = page.nextCursor ?? undefined;
|
|
140
|
+
} while (cursor);
|
|
141
|
+
store.progress.discovered(connection.id);
|
|
142
|
+
store.coverage.finish(connection.id, true);
|
|
143
|
+
if (failures.length)
|
|
144
|
+
throw new Error(`${failures.length} session(s) need attention. ${failures[0]}`);
|
|
145
|
+
}
|
|
146
|
+
export class CollectorInterruptedError extends Error {
|
|
147
|
+
}
|
|
148
|
+
export class CollectorScopeChangedError extends Error {
|
|
149
|
+
}
|
|
@@ -61,6 +61,7 @@ export async function selectCollectorBranch(directory, connectionId, nativeSessi
|
|
|
61
61
|
// Never admit previously queued sibling evidence after a changed choice.
|
|
62
62
|
for (const table of ["source_scans", "checkpoints", "pending"])
|
|
63
63
|
store.database.prepare(`DELETE FROM ${table} WHERE connection_id=?`).run(connectionId);
|
|
64
|
+
store.snapshots.collect();
|
|
64
65
|
const errors = new CollectorErrors(store);
|
|
65
66
|
errors.set(connectionId, "source", errors.source(connectionId));
|
|
66
67
|
store.progress.reset(connectionId);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { importCalendarMatches, parseImportCron } from "./collector-cron.js";
|
|
2
|
+
import { scheduleExpression } from "./collector-schedule.js";
|
|
3
|
+
/** A tiny, dependency-free process for OS schedulers that cannot express cron.
|
|
4
|
+
* An unmatched calendar exits before importing the CLI or opening source files. */
|
|
5
|
+
export function collectorCalendarGate(input) {
|
|
6
|
+
const calendars = input.schedules.map(schedule => parseImportCron(scheduleExpression(schedule)).calendar);
|
|
7
|
+
return `"use strict";
|
|
8
|
+
const matches = ${importCalendarMatches.toString()};
|
|
9
|
+
const calendars = ${JSON.stringify(calendars)};
|
|
10
|
+
if (calendars.some(calendar => matches(calendar, new Date()))) {
|
|
11
|
+
const { spawn } = require("node:child_process");
|
|
12
|
+
const child = spawn(${JSON.stringify(input.executable)}, ${JSON.stringify(input.args)}, { stdio: "inherit", windowsHide: true });
|
|
13
|
+
process.on("SIGINT", () => child.kill("SIGINT"));
|
|
14
|
+
process.on("SIGTERM", () => child.kill("SIGTERM"));
|
|
15
|
+
child.on("error", error => { console.error(error.message); process.exitCode = 1; });
|
|
16
|
+
child.on("exit", (code, signal) => { process.exitCode = code ?? (signal === "SIGINT" ? 130 : 143); });
|
|
17
|
+
}
|
|
18
|
+
`;
|
|
19
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export const COLLECTOR_DEFAULTS = {
|
|
2
|
-
reconcileMs: 30000,
|
|
3
2
|
heartbeatMs: 10000,
|
|
4
3
|
staleMs: 60000,
|
|
5
4
|
maxPending: 10000,
|
|
6
|
-
maxBytes:
|
|
5
|
+
maxBytes: 128 * 1024 * 1024,
|
|
7
6
|
batchCases: 20,
|
|
8
|
-
|
|
7
|
+
runMs: 60 * 60 * 1000,
|
|
8
|
+
requestMs: 60000,
|
|
9
|
+
attemptsPerRun: 3,
|
|
9
10
|
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { contentHash } from "@openpond/harness";
|
|
2
|
+
import { CollectorCoverageManifestSchema, CollectorCoverageSessionSchema, summarizeCollectorCoverage, } from "../connected-evidence/collector-coverage-contracts.js";
|
|
3
|
+
/** Retains the latest acquisition inventory independently of queue receipts.
|
|
4
|
+
* Native paths are hashed; this authority never stores transcript bytes. */
|
|
5
|
+
export class CollectorCoverage {
|
|
6
|
+
store;
|
|
7
|
+
constructor(store) {
|
|
8
|
+
this.store = store;
|
|
9
|
+
store.database.exec(`CREATE TABLE IF NOT EXISTS coverage_sessions(
|
|
10
|
+
connection_id TEXT NOT NULL,source_key_hash TEXT NOT NULL,value TEXT NOT NULL,
|
|
11
|
+
PRIMARY KEY(connection_id,source_key_hash));`);
|
|
12
|
+
}
|
|
13
|
+
begin(connection, jobId, startedAt, to) {
|
|
14
|
+
const manifest = CollectorCoverageManifestSchema.parse({
|
|
15
|
+
schemaVersion: "openpond.collectorCoverageManifest.v1",
|
|
16
|
+
jobId,
|
|
17
|
+
connectionId: connection.id,
|
|
18
|
+
connectionRevision: connection.revision,
|
|
19
|
+
machineId: connection.source.machineId,
|
|
20
|
+
sourceInstanceId: connection.source.instanceId,
|
|
21
|
+
from: connection.since,
|
|
22
|
+
to,
|
|
23
|
+
startedAt,
|
|
24
|
+
finishedAt: null,
|
|
25
|
+
listingComplete: false,
|
|
26
|
+
pendingOperations: this.store.queued(connection.id),
|
|
27
|
+
sessions: [],
|
|
28
|
+
});
|
|
29
|
+
this.store.database.exec("BEGIN IMMEDIATE");
|
|
30
|
+
try {
|
|
31
|
+
this.store.database
|
|
32
|
+
.prepare("DELETE FROM coverage_sessions WHERE connection_id=?")
|
|
33
|
+
.run(connection.id);
|
|
34
|
+
this.store.set(`coverage:${connection.id}`, JSON.stringify(manifest));
|
|
35
|
+
this.store.database.exec("COMMIT");
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
this.store.database.exec("ROLLBACK");
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
record(connectionId, session) {
|
|
43
|
+
const value = CollectorCoverageSessionSchema.parse(session);
|
|
44
|
+
const serialized = JSON.stringify(value);
|
|
45
|
+
const prior = this.store.database
|
|
46
|
+
.prepare("SELECT COALESCE(SUM(length(value)),0) AS bytes FROM coverage_sessions WHERE connection_id=? AND source_key_hash!=?")
|
|
47
|
+
.get(connectionId, value.sourceKeyHash);
|
|
48
|
+
const boundaryUsage = this.store.database
|
|
49
|
+
.prepare("SELECT COALESCE(SUM(json_array_length(n.value,'$.boundaries')),0) AS n FROM coverage_sessions c,json_each(c.value,'$.normalized') n WHERE c.connection_id=? AND c.source_key_hash!=?")
|
|
50
|
+
.get(connectionId, value.sourceKeyHash);
|
|
51
|
+
if (Buffer.byteLength(serialized) + Number(prior?.bytes ?? 0) >
|
|
52
|
+
8 * 1024 * 1024 ||
|
|
53
|
+
value.normalized.reduce((sum, item) => sum + item.boundaries.length, 0) +
|
|
54
|
+
Number(boundaryUsage?.n ?? 0) >
|
|
55
|
+
20_000)
|
|
56
|
+
throw new Error("Source coverage exceeds its bounded inventory. Select a narrower import window.");
|
|
57
|
+
const count = Number(this.store.database
|
|
58
|
+
.prepare("SELECT COUNT(*) AS n FROM coverage_sessions WHERE connection_id=?")
|
|
59
|
+
.get(connectionId)?.n ?? 0);
|
|
60
|
+
const exists = this.store.database
|
|
61
|
+
.prepare("SELECT 1 FROM coverage_sessions WHERE connection_id=? AND source_key_hash=?")
|
|
62
|
+
.get(connectionId, value.sourceKeyHash);
|
|
63
|
+
if (!exists && count >= 10_000)
|
|
64
|
+
throw new Error("Source inventory exceeds 10,000 sessions. Select a narrower import window.");
|
|
65
|
+
this.store.database
|
|
66
|
+
.prepare("INSERT INTO coverage_sessions VALUES(?,?,?) ON CONFLICT(connection_id,source_key_hash) DO UPDATE SET value=excluded.value")
|
|
67
|
+
.run(connectionId, value.sourceKeyHash, serialized);
|
|
68
|
+
}
|
|
69
|
+
manifest(connectionId) {
|
|
70
|
+
const raw = this.store.setting(`coverage:${connectionId}`);
|
|
71
|
+
if (!raw)
|
|
72
|
+
return null;
|
|
73
|
+
const sessions = this.store.database
|
|
74
|
+
.prepare("SELECT value FROM coverage_sessions WHERE connection_id=? ORDER BY source_key_hash")
|
|
75
|
+
.all(connectionId)
|
|
76
|
+
.map((row) => CollectorCoverageSessionSchema.parse(JSON.parse(String(row.value))));
|
|
77
|
+
return CollectorCoverageManifestSchema.parse({
|
|
78
|
+
...JSON.parse(raw),
|
|
79
|
+
pendingOperations: this.store.queued(connectionId),
|
|
80
|
+
sessions,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
finish(connectionId, listingComplete) {
|
|
84
|
+
const manifest = this.manifest(connectionId);
|
|
85
|
+
if (!manifest)
|
|
86
|
+
throw new Error("Source coverage was not initialized.");
|
|
87
|
+
manifest.listingComplete ||= listingComplete;
|
|
88
|
+
manifest.finishedAt ??= new Date().toISOString();
|
|
89
|
+
const { sessions: _sessions, ...metadata } = CollectorCoverageManifestSchema.parse(manifest);
|
|
90
|
+
this.store.set(`coverage:${connectionId}`, JSON.stringify({ ...metadata, sessions: [] }));
|
|
91
|
+
return manifest;
|
|
92
|
+
}
|
|
93
|
+
summary(connectionId) {
|
|
94
|
+
const manifest = this.manifest(connectionId);
|
|
95
|
+
return manifest ? summarizeCollectorCoverage(manifest) : null;
|
|
96
|
+
}
|
|
97
|
+
static sourceKey(scanKey) {
|
|
98
|
+
return contentHash(scanKey);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { CronExpressionParser } from "cron-parser";
|
|
2
|
+
/** Standard five-field cron: numbers, names, wildcards, lists, ranges and steps.
|
|
3
|
+
* Excludes seconds and Quartz/randomized extensions so runs stay deterministic. */
|
|
4
|
+
export function parseImportCron(expression, now = new Date()) {
|
|
5
|
+
if (expression.length > 512)
|
|
6
|
+
throw new Error("Cron expressions must be at most 512 characters.");
|
|
7
|
+
const fields = expression.trim().split(/\s+/u);
|
|
8
|
+
if (fields.length !== 5)
|
|
9
|
+
throw new Error("Use five cron fields: minute hour day-of-month month day-of-week.");
|
|
10
|
+
const names = [[], [], [], ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"],
|
|
11
|
+
["sun", "mon", "tue", "wed", "thu", "fri", "sat"]];
|
|
12
|
+
const numeric = fields.map((field, index) => field.replace(/[a-z]+/giu, name => {
|
|
13
|
+
const value = names[index].indexOf(name.toLowerCase());
|
|
14
|
+
if (value < 0)
|
|
15
|
+
throw new Error("Cron supports standard month/weekday names, numbers, *, lists, ranges and steps.");
|
|
16
|
+
return String(value + (index === 3 ? 1 : 0));
|
|
17
|
+
}));
|
|
18
|
+
if (numeric.some(field => !/^[\d*,/\-]+$/u.test(field)))
|
|
19
|
+
throw new Error("Cron supports numbers, *, lists, ranges and steps; second-level schedules are not supported.");
|
|
20
|
+
const normalized = numeric.join(" ");
|
|
21
|
+
const interval = CronExpressionParser.parse(normalized, {
|
|
22
|
+
currentDate: now, tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
23
|
+
});
|
|
24
|
+
const cron = interval.fields;
|
|
25
|
+
if (cron.minute.values.length !== 1)
|
|
26
|
+
throw new Error("Imports support at most one scheduled run per hour. Choose a single cron minute, such as 0 or 30.");
|
|
27
|
+
const calendar = {
|
|
28
|
+
minute: [...cron.minute.values], hour: [...cron.hour.values], month: [...cron.month.values],
|
|
29
|
+
dayOfMonth: cron.dayOfMonth.values.map(Number), dayOfWeek: [...new Set(cron.dayOfWeek.values.map(value => Number(value) % 7))],
|
|
30
|
+
anyDayOfMonth: cron.dayOfMonth.isWildcard, anyDayOfWeek: cron.dayOfWeek.isWildcard,
|
|
31
|
+
};
|
|
32
|
+
return { expression: normalized, calendar, interval };
|
|
33
|
+
}
|
|
34
|
+
/** Deliberately self-contained: the same function is embedded in the tiny OS
|
|
35
|
+
* launcher, which has no package imports and never reads source transcripts. */
|
|
36
|
+
export function importCalendarMatches(calendar, now) {
|
|
37
|
+
const dom = calendar.dayOfMonth.includes(now.getDate());
|
|
38
|
+
const dow = calendar.dayOfWeek.includes(now.getDay());
|
|
39
|
+
const day = (!calendar.anyDayOfMonth && !calendar.anyDayOfWeek && (dom || dow)) ||
|
|
40
|
+
(dom && calendar.anyDayOfWeek) || (calendar.anyDayOfMonth && !calendar.anyDayOfWeek && dow);
|
|
41
|
+
return day && calendar.month.includes(now.getMonth() + 1) &&
|
|
42
|
+
calendar.hour.includes(now.getHours()) && calendar.minute.includes(now.getMinutes());
|
|
43
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { importCalendarMatches, parseImportCron } from "./collector-cron.js";
|
|
2
|
+
export const SYNC_WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
3
|
+
/** Calendar times follow the host timezone, including daylight-saving changes. */
|
|
4
|
+
export function syncTime(value) {
|
|
5
|
+
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/u.test(value))
|
|
6
|
+
throw new Error("Use a sync time in 24-hour HH:MM format, such as 00:00.");
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
export function validateCollectorSchedule(schedule) {
|
|
10
|
+
if (schedule.frequency === "hourly")
|
|
11
|
+
return { frequency: schedule.frequency };
|
|
12
|
+
if (schedule.frequency === "cron") {
|
|
13
|
+
const parsed = parseImportCron(schedule.expression);
|
|
14
|
+
parsed.interval.next(); // Reject impossible calendars before installing an OS trigger.
|
|
15
|
+
return { frequency: "cron", expression: parsed.expression };
|
|
16
|
+
}
|
|
17
|
+
const time = syncTime(schedule.time);
|
|
18
|
+
if (schedule.frequency === "daily")
|
|
19
|
+
return { frequency: "daily", time };
|
|
20
|
+
if (schedule.frequency === "weekly" && Number.isInteger(schedule.day) && schedule.day >= 0 && schedule.day <= 6)
|
|
21
|
+
return { frequency: "weekly", time, day: schedule.day };
|
|
22
|
+
throw new Error("Choose a supported import frequency and, for weekly imports, a weekday from Sunday (0) to Saturday (6).");
|
|
23
|
+
}
|
|
24
|
+
export function scheduleExpression(schedule) {
|
|
25
|
+
if (schedule.frequency === "cron")
|
|
26
|
+
return schedule.expression;
|
|
27
|
+
if (schedule.frequency === "hourly")
|
|
28
|
+
return "0 * * * *";
|
|
29
|
+
const [hour, minute] = schedule.time.split(":").map(Number);
|
|
30
|
+
return `${minute} ${hour} * * ${schedule.frequency === "weekly" ? schedule.day : "*"}`;
|
|
31
|
+
}
|
|
32
|
+
export function scheduleDescription(schedule) {
|
|
33
|
+
if (schedule.frequency === "cron")
|
|
34
|
+
return `cron ${schedule.expression}`;
|
|
35
|
+
if (schedule.frequency === "hourly")
|
|
36
|
+
return "hourly at minute 00";
|
|
37
|
+
return schedule.frequency === "daily" ? `daily at ${schedule.time}` : `weekly on ${SYNC_WEEKDAYS[schedule.day]} at ${schedule.time}`;
|
|
38
|
+
}
|
|
39
|
+
export function localDay(now) {
|
|
40
|
+
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
41
|
+
}
|
|
42
|
+
export function scheduledMinute(now) {
|
|
43
|
+
return `${localDay(now)}T${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
|
44
|
+
}
|
|
45
|
+
export function nextScheduledSync(schedule, now = new Date()) {
|
|
46
|
+
const { interval, calendar } = parseImportCron(scheduleExpression(schedule), now);
|
|
47
|
+
// cron-parser can shift a missing spring-forward time to the next hour.
|
|
48
|
+
// Only an actual matching wall-clock minute is a valid scheduled run.
|
|
49
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
50
|
+
const next = interval.next().toDate();
|
|
51
|
+
if (importCalendarMatches(calendar, next))
|
|
52
|
+
return next.toISOString();
|
|
53
|
+
}
|
|
54
|
+
throw new Error("Unable to determine the next sync time.");
|
|
55
|
+
}
|
|
56
|
+
/** Reject delayed wake deliveries and duplicate launches within a scheduled minute. */
|
|
57
|
+
export function scheduledSyncDue(schedule, lastMinute, now = new Date()) {
|
|
58
|
+
if (!schedule || lastMinute === scheduledMinute(now))
|
|
59
|
+
return false;
|
|
60
|
+
return importCalendarMatches(parseImportCron(scheduleExpression(schedule), now).calendar, now);
|
|
61
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { SYNC_WEEKDAYS, validateCollectorSchedule } from "./collector-schedule.js";
|
|
2
|
+
import { collectorCalendarGate } from "./collector-calendar-gate.js";
|
|
3
|
+
const WINDOWS_WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
4
|
+
export const xml = (value) => value.replace(/&/gu, "&").replace(/</gu, "<").replace(/>/gu, ">").replace(/"/gu, """);
|
|
5
|
+
export const systemdQuote = (value) => `"${value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"').replace(/%/gu, "%%").replace(/\$/gu, () => "$$")}"`;
|
|
6
|
+
export const windowsQuote = (value) => `"${value.replace(/(\\*)"/gu, '$1$1\\"').replace(/(\\+)$/u, "$1$1")}"`;
|
|
7
|
+
export function collectorServiceFiles(input) {
|
|
8
|
+
const schedules = [...new Map(input.schedules.map(value => {
|
|
9
|
+
const schedule = validateCollectorSchedule(value);
|
|
10
|
+
return [JSON.stringify(schedule), schedule];
|
|
11
|
+
})).values()];
|
|
12
|
+
const command = [input.executable, input.gatePath];
|
|
13
|
+
const triggers = schedules.some(schedule => schedule.frequency === "cron")
|
|
14
|
+
? [{ frequency: "calendar-tick" }] : schedules;
|
|
15
|
+
const cron = (schedule) => {
|
|
16
|
+
if (schedule.frequency === "calendar-tick")
|
|
17
|
+
return "*-*-* *:*:00";
|
|
18
|
+
if (schedule.frequency === "hourly")
|
|
19
|
+
return "*-*-* *:00:00";
|
|
20
|
+
if (schedule.frequency === "cron")
|
|
21
|
+
throw new Error("Cron needs the calendar gate.");
|
|
22
|
+
return `${schedule.frequency === "weekly" ? `${SYNC_WEEKDAYS[schedule.day]} ` : ""}*-*-* ${schedule.time}:00`;
|
|
23
|
+
};
|
|
24
|
+
const launchd = (schedule) => {
|
|
25
|
+
if (schedule.frequency === "calendar-tick")
|
|
26
|
+
return "<dict/>";
|
|
27
|
+
if (schedule.frequency === "hourly")
|
|
28
|
+
return "<dict><key>Minute</key><integer>0</integer></dict>";
|
|
29
|
+
if (schedule.frequency === "cron")
|
|
30
|
+
throw new Error("Cron needs the calendar gate.");
|
|
31
|
+
const [hour, minute] = schedule.time.split(":").map(Number);
|
|
32
|
+
return `<dict>${schedule.frequency === "weekly" ? `<key>Weekday</key><integer>${schedule.day}</integer>` : ""}<key>Hour</key><integer>${hour}</integer><key>Minute</key><integer>${minute}</integer></dict>`;
|
|
33
|
+
};
|
|
34
|
+
const windows = (schedule) => {
|
|
35
|
+
if (schedule.frequency === "calendar-tick" || schedule.frequency === "hourly")
|
|
36
|
+
return `<CalendarTrigger><Repetition><Interval>${schedule.frequency === "hourly" ? "PT1H" : "PT1M"}</Interval><Duration>P1D</Duration><StopAtDurationEnd>false</StopAtDurationEnd></Repetition><StartBoundary>2020-01-01T00:00:00</StartBoundary><Enabled>true</Enabled><ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay></CalendarTrigger>`;
|
|
37
|
+
if (schedule.frequency === "cron")
|
|
38
|
+
throw new Error("Cron needs the calendar gate.");
|
|
39
|
+
return `<CalendarTrigger><StartBoundary>2020-01-01T${schedule.time}:00</StartBoundary><Enabled>true</Enabled>${schedule.frequency === "daily" ? "<ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay>" : `<ScheduleByWeek><WeeksInterval>1</WeeksInterval><DaysOfWeek><${WINDOWS_WEEKDAYS[schedule.day]}/></DaysOfWeek></ScheduleByWeek>`}</CalendarTrigger>`;
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
gate: collectorCalendarGate({ executable: input.executable, args: input.args, schedules }),
|
|
43
|
+
service: `[Unit]\nDescription=OpenPond conversation import job\nAfter=network-online.target\n[Service]\nType=oneshot\n${input.environment.map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`).join("\n")}\nExecStart=${command.map(systemdQuote).join(" ")}\nTimeoutStartSec=65min\nTimeoutStopSec=15s\nRestart=no\nNice=10\nUMask=0077\nNoNewPrivileges=true\n`,
|
|
44
|
+
timer: `[Unit]\nDescription=OpenPond scheduled conversation imports\n[Timer]\n${triggers.map(schedule => `OnCalendar=${cron(schedule)}`).join("\n")}\nAccuracySec=1s\nRandomizedDelaySec=0\nPersistent=false\nWakeSystem=false\nUnit=${input.name}.service\n[Install]\nWantedBy=timers.target\n`,
|
|
45
|
+
plist: `<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>Label</key><string>${input.name}</string><key>ProgramArguments</key><array>${command.map(value => `<string>${xml(value)}</string>`).join("")}</array><key>EnvironmentVariables</key><dict>${input.environment.map(([key, value]) => `<key>${xml(key)}</key><string>${xml(value)}</string>`).join("")}</dict><key>StartCalendarInterval</key><array>${triggers.map(launchd).join("")}</array><key>RunAtLoad</key><false/><key>KeepAlive</key><false/><key>Nice</key><integer>10</integer></dict></plist>`,
|
|
46
|
+
windowsTask(user, script) {
|
|
47
|
+
return `<?xml version="1.0"?><Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"><Triggers>${triggers.map(windows).join("")}</Triggers><Principals><Principal id="Owner"><UserId>${xml(user)}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals><Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><StartWhenAvailable>false</StartWhenAvailable><WakeToRun>false</WakeToRun><ExecutionTimeLimit>PT65M</ExecutionTimeLimit></Settings><Actions Context="Owner"><Exec><Command>powershell.exe</Command><Arguments>${xml(["-NoProfile", "-NonInteractive", "-File", script].map(windowsQuote).join(" "))}</Arguments></Exec></Actions></Task>`;
|
|
48
|
+
},
|
|
49
|
+
powershell: `${input.environment.map(([key, value]) => `$env:${key} = '${value.replace(/'/gu, "''")}'`).join("\n")}\n& ${command.map(value => `'${value.replace(/'/gu, "''")}'`).join(" ")}\nexit $LASTEXITCODE\n`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { setImmediate } from "node:timers/promises";
|
|
2
|
+
import { contentHash } from "@openpond/harness";
|
|
3
|
+
export class CollectorQueueFullError extends Error {
|
|
4
|
+
constructor() { super("Import queue is full. Pending uploads must finish before more history can be read."); }
|
|
5
|
+
}
|
|
6
|
+
/** Batches reference an immutable source snapshot instead of duplicating the
|
|
7
|
+
* whole transcript once for every twenty selected turns. */
|
|
8
|
+
export class CollectorSnapshots {
|
|
9
|
+
database;
|
|
10
|
+
constructor(database) {
|
|
11
|
+
this.database = database;
|
|
12
|
+
database.exec(`
|
|
13
|
+
CREATE TABLE IF NOT EXISTS collector_snapshots(hash TEXT PRIMARY KEY,payload TEXT NOT NULL,bytes INTEGER NOT NULL);
|
|
14
|
+
CREATE TABLE IF NOT EXISTS pending_snapshots(operation_id TEXT PRIMARY KEY,snapshot_hash TEXT NOT NULL);
|
|
15
|
+
CREATE INDEX IF NOT EXISTS pending_snapshots_hash ON pending_snapshots(snapshot_hash);
|
|
16
|
+
`);
|
|
17
|
+
}
|
|
18
|
+
prepare(files) {
|
|
19
|
+
const payload = JSON.stringify(files);
|
|
20
|
+
return { hash: contentHash(files), payload, bytes: Buffer.byteLength(payload) };
|
|
21
|
+
}
|
|
22
|
+
exists(hash) {
|
|
23
|
+
return !!this.database.prepare("SELECT 1 FROM collector_snapshots WHERE hash=?").get(hash);
|
|
24
|
+
}
|
|
25
|
+
retain(id, snapshot) {
|
|
26
|
+
this.database.prepare("INSERT OR IGNORE INTO collector_snapshots VALUES(?,?,?)").run(snapshot.hash, snapshot.payload, snapshot.bytes);
|
|
27
|
+
this.database.prepare("INSERT OR REPLACE INTO pending_snapshots VALUES(?,?)").run(id, snapshot.hash);
|
|
28
|
+
}
|
|
29
|
+
files(id) {
|
|
30
|
+
const row = this.database.prepare("SELECT payload FROM collector_snapshots JOIN pending_snapshots ON hash=snapshot_hash WHERE operation_id=?").get(id);
|
|
31
|
+
if (!row)
|
|
32
|
+
throw new Error("The pending import's source snapshot is missing.");
|
|
33
|
+
return JSON.parse(String(row.payload));
|
|
34
|
+
}
|
|
35
|
+
bytes(connectionId) {
|
|
36
|
+
return Number(connectionId
|
|
37
|
+
? this.database.prepare("SELECT COALESCE(SUM(bytes),0) AS n FROM collector_snapshots WHERE hash IN (SELECT snapshot_hash FROM pending_snapshots JOIN pending ON operation_id=pending.id WHERE connection_id=?)").get(connectionId)?.n
|
|
38
|
+
: this.database.prepare("SELECT COALESCE(SUM(bytes),0) AS n FROM collector_snapshots").get()?.n);
|
|
39
|
+
}
|
|
40
|
+
collect() {
|
|
41
|
+
this.database.exec(`DELETE FROM pending_snapshots WHERE operation_id NOT IN (SELECT id FROM pending);
|
|
42
|
+
DELETE FROM collector_snapshots WHERE hash NOT IN (SELECT snapshot_hash FROM pending_snapshots);`);
|
|
43
|
+
}
|
|
44
|
+
/** Upgrade retained queues only in the owned worker, never in a status read.
|
|
45
|
+
* Each operation commits independently so interruption preserves all work. */
|
|
46
|
+
async migrate(cancelled) {
|
|
47
|
+
const ids = this.database.prepare("SELECT id FROM pending WHERE id NOT IN (SELECT operation_id FROM pending_snapshots)").all();
|
|
48
|
+
for (const row of ids) {
|
|
49
|
+
await setImmediate();
|
|
50
|
+
if (cancelled())
|
|
51
|
+
return;
|
|
52
|
+
const stored = this.database.prepare("SELECT payload FROM pending WHERE id=?").get(row.id);
|
|
53
|
+
if (!stored)
|
|
54
|
+
continue;
|
|
55
|
+
const { files, ...entry } = JSON.parse(String(stored.payload));
|
|
56
|
+
const snapshot = this.prepare(files), payload = JSON.stringify(entry);
|
|
57
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
58
|
+
try {
|
|
59
|
+
this.retain(String(row.id), snapshot);
|
|
60
|
+
this.database.prepare("UPDATE pending SET payload=?,bytes=? WHERE id=?").run(payload, Buffer.byteLength(payload), row.id);
|
|
61
|
+
this.database.exec("COMMIT");
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
this.database.exec("ROLLBACK");
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
this.collect();
|
|
69
|
+
}
|
|
70
|
+
}
|