@h-ai/runner 1.0.0-beta.24 → 1.0.0-beta.25
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.en.md +291 -38
- package/README.ja.md +291 -38
- package/README.md +291 -38
- package/dist/index.d.ts +4 -1
- package/dist/index.js +309 -54
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { randomUUID, createHash } from 'crypto';
|
|
|
4
4
|
import { core, ok, err } from '@h-ai/core';
|
|
5
5
|
import { execFile, spawn } from 'child_process';
|
|
6
6
|
import { once } from 'events';
|
|
7
|
-
import { lstat, chmod, rm, readFile, stat, realpath, mkdtemp, writeFile, mkdir } from 'fs/promises';
|
|
7
|
+
import { lstat, chmod, rm, readFile, readlink, stat, realpath, mkdtemp, writeFile, mkdir } from 'fs/promises';
|
|
8
8
|
import { tmpdir } from 'os';
|
|
9
9
|
import { join, posix, basename } from 'path';
|
|
10
10
|
import { promisify } from 'util';
|
|
@@ -62,6 +62,8 @@ var RunnerConfigSchema = z.strictObject({
|
|
|
62
62
|
maxControlRunning: z.number().int().min(1).max(1e3).default(8),
|
|
63
63
|
maxQueued: z.number().int().min(1).max(1e5).default(5e3),
|
|
64
64
|
maxEvents: z.number().int().min(1).max(1e5).default(1e4),
|
|
65
|
+
/** 已结算任务、事件和幂等键的保留期限。未核验的 unknown 不受该期限影响。 */
|
|
66
|
+
retentionMs: z.number().int().min(36e5).max(365 * 864e5).default(7 * 864e5),
|
|
65
67
|
closeTimeoutMs: z.number().int().min(100).max(12e4).default(2e4),
|
|
66
68
|
environments: z.array(environment).max(1e3).default([])
|
|
67
69
|
}).refine((value) => value.pollMs * 3 < value.leaseMs && new Set(value.environments.map((entry) => entry.id)).size === value.environments.length);
|
|
@@ -499,7 +501,7 @@ async function startContainer(engine, environment2, resources2, launch, signal,
|
|
|
499
501
|
return closing;
|
|
500
502
|
};
|
|
501
503
|
let activated = false;
|
|
502
|
-
const
|
|
504
|
+
const process3 = {
|
|
503
505
|
stdin: child.stdin,
|
|
504
506
|
stdout: child.stdout,
|
|
505
507
|
stderr: child.stderr,
|
|
@@ -530,7 +532,7 @@ async function startContainer(engine, environment2, resources2, launch, signal,
|
|
|
530
532
|
await new Promise((resolve, reject) => {
|
|
531
533
|
child.stdin.write("+", (error) => error ? reject(error) : resolve());
|
|
532
534
|
});
|
|
533
|
-
return ok(
|
|
535
|
+
return ok(process3);
|
|
534
536
|
} catch {
|
|
535
537
|
return unavailable();
|
|
536
538
|
}
|
|
@@ -580,7 +582,7 @@ async function startContainer(engine, environment2, resources2, launch, signal,
|
|
|
580
582
|
return (await stop()).success ? unavailable() : uncertain();
|
|
581
583
|
}
|
|
582
584
|
}
|
|
583
|
-
return ok(
|
|
585
|
+
return ok(process3);
|
|
584
586
|
} catch {
|
|
585
587
|
if (envFile) {
|
|
586
588
|
try {
|
|
@@ -609,10 +611,83 @@ async function prepareWarmContainer(engine, environment2, signal, record) {
|
|
|
609
611
|
inputs: {}
|
|
610
612
|
}, signal, record, true);
|
|
611
613
|
}
|
|
614
|
+
var processIdentity = z.strictObject({
|
|
615
|
+
pid: z.number().int().positive().max(2 ** 31 - 1),
|
|
616
|
+
hostKey: z.string().regex(/^[a-f\d]{64}$/u),
|
|
617
|
+
started: z.string().regex(/^\d+$/u).max(20)
|
|
618
|
+
});
|
|
619
|
+
async function hostKey() {
|
|
620
|
+
const [machine, boot, namespace] = await Promise.all([
|
|
621
|
+
readFile("/etc/machine-id", "utf8"),
|
|
622
|
+
readFile("/proc/sys/kernel/random/boot_id", "utf8"),
|
|
623
|
+
readlink("/proc/self/ns/pid")
|
|
624
|
+
]);
|
|
625
|
+
if (!/^[a-f\d]{32}$/u.test(machine.trim()) || !/^[a-f\d-]{36}$/u.test(boot.trim()) || !/^pid:\[\d+\]$/u.test(namespace)) {
|
|
626
|
+
throw new Error("Runner host identity unavailable");
|
|
627
|
+
}
|
|
628
|
+
return createHash("sha256").update(JSON.stringify([
|
|
629
|
+
machine.trim(),
|
|
630
|
+
boot.trim(),
|
|
631
|
+
namespace
|
|
632
|
+
])).digest("hex");
|
|
633
|
+
}
|
|
634
|
+
async function startTime(pid) {
|
|
635
|
+
const raw = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
636
|
+
const index = raw.lastIndexOf(") ");
|
|
637
|
+
const field = index < 0 ? void 0 : raw.slice(index + 2).split(/\s+/u)[19];
|
|
638
|
+
if (!field || !/^\d+$/u.test(field))
|
|
639
|
+
throw new Error("Runner process identity unavailable");
|
|
640
|
+
return field;
|
|
641
|
+
}
|
|
642
|
+
async function readRunnerProcess() {
|
|
643
|
+
if (process.platform !== "linux")
|
|
644
|
+
return void 0;
|
|
645
|
+
try {
|
|
646
|
+
const [host, started] = await Promise.all([
|
|
647
|
+
hostKey(),
|
|
648
|
+
startTime(process.pid)
|
|
649
|
+
]);
|
|
650
|
+
return { pid: process.pid, hostKey: host, started };
|
|
651
|
+
} catch {
|
|
652
|
+
return void 0;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
async function confirmRunnerProcessReleased(identity) {
|
|
656
|
+
const record = processIdentity.safeParse(identity);
|
|
657
|
+
if (!record.success || process.platform !== "linux")
|
|
658
|
+
return false;
|
|
659
|
+
const { pid, hostKey: previous, started } = record.data;
|
|
660
|
+
try {
|
|
661
|
+
if (previous !== await hostKey())
|
|
662
|
+
return false;
|
|
663
|
+
try {
|
|
664
|
+
return await startTime(pid) !== started;
|
|
665
|
+
} catch (error) {
|
|
666
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
|
667
|
+
return false;
|
|
668
|
+
try {
|
|
669
|
+
process.kill(pid, 0);
|
|
670
|
+
return false;
|
|
671
|
+
} catch (cause) {
|
|
672
|
+
return cause instanceof Error && "code" in cause && cause.code === "ESRCH";
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
} catch {
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
612
679
|
var EXECUTIONS = "hai_runner_executions";
|
|
613
680
|
var EVENTS = "hai_runner_events";
|
|
614
681
|
var GATE = "hai_runner_gate";
|
|
615
682
|
var ACTIVE = "(state IN ('running', 'cancelling') OR (state = 'unknown' AND reconciled_at IS NULL))";
|
|
683
|
+
var RETENTION_STATES = [
|
|
684
|
+
"succeeded",
|
|
685
|
+
"failed",
|
|
686
|
+
"cancelled",
|
|
687
|
+
"unknown"
|
|
688
|
+
];
|
|
689
|
+
var RETENTION_BATCH = 128;
|
|
690
|
+
var MAX_EVENT_BYTES = 16 * 1024 * 1024;
|
|
616
691
|
var text = { type: "TEXT", notNull: true };
|
|
617
692
|
var nullableText = { type: "TEXT" };
|
|
618
693
|
var integer = { type: "BIGINT", notNull: true };
|
|
@@ -629,6 +704,7 @@ var executions = {
|
|
|
629
704
|
reason: nullableText,
|
|
630
705
|
owner_node: nullableText,
|
|
631
706
|
owner_generation: nullableText,
|
|
707
|
+
owner_process_json: payload,
|
|
632
708
|
pinned_node: nullableText,
|
|
633
709
|
pinned_generation: nullableText,
|
|
634
710
|
parent_id: nullableText,
|
|
@@ -636,6 +712,7 @@ var executions = {
|
|
|
636
712
|
queue_until: integer,
|
|
637
713
|
run_until: integer,
|
|
638
714
|
event_sequence: integer,
|
|
715
|
+
event_bytes: { ...integer, defaultValue: 0 },
|
|
639
716
|
cpu_milli: integer,
|
|
640
717
|
memory_mib: integer,
|
|
641
718
|
pids: integer,
|
|
@@ -673,13 +750,12 @@ function hash(value) {
|
|
|
673
750
|
function databaseValue(value) {
|
|
674
751
|
return typeof value === "string" ? JSON.parse(value) : value;
|
|
675
752
|
}
|
|
676
|
-
function project(row) {
|
|
677
|
-
const request = databaseValue(row.request_json);
|
|
753
|
+
function project(row, environmentId) {
|
|
678
754
|
return {
|
|
679
755
|
id: row.id,
|
|
680
756
|
status: row.state,
|
|
681
757
|
owner: row.owner_node && row.owner_generation ? { nodeId: row.owner_node, generation: row.owner_generation } : void 0,
|
|
682
|
-
environmentId
|
|
758
|
+
environmentId,
|
|
683
759
|
output: row.result_json === null ? void 0 : databaseValue(row.result_json).value,
|
|
684
760
|
failure: row.reason ?? void 0,
|
|
685
761
|
createdAt: Number(row.created_at),
|
|
@@ -687,6 +763,9 @@ function project(row) {
|
|
|
687
763
|
reconciledAt: row.reconciled_at === null ? void 0 : Number(row.reconciled_at)
|
|
688
764
|
};
|
|
689
765
|
}
|
|
766
|
+
function projectRequest(row) {
|
|
767
|
+
return project(row, databaseValue(row.request_json).environment?.id);
|
|
768
|
+
}
|
|
690
769
|
function unavailable2() {
|
|
691
770
|
return err(HaiRunnerError.UNAVAILABLE, runnerM("runner_unavailable"));
|
|
692
771
|
}
|
|
@@ -731,6 +810,7 @@ function within(actual, ceiling) {
|
|
|
731
810
|
async function readyStore() {
|
|
732
811
|
if (!reldb.isInitialized)
|
|
733
812
|
return unavailable2();
|
|
813
|
+
const addedColumns = ["event_bytes", "owner_process_json"];
|
|
734
814
|
for (const [name, def] of [
|
|
735
815
|
[
|
|
736
816
|
GATE,
|
|
@@ -753,8 +833,19 @@ async function readyStore() {
|
|
|
753
833
|
]) {
|
|
754
834
|
const result = await reldb.ddl.createTable(name, def);
|
|
755
835
|
if (!result.success) {
|
|
756
|
-
const
|
|
757
|
-
if (!
|
|
836
|
+
const existing2 = await reldb.schemaColumns(name);
|
|
837
|
+
if (!existing2.success || Object.keys(def).some((column) => !existing2.data.includes(column) && !(name === EXECUTIONS && addedColumns.includes(column))))
|
|
838
|
+
return unavailable2();
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const existing = await reldb.schemaColumns(EXECUTIONS);
|
|
842
|
+
if (!existing.success)
|
|
843
|
+
return unavailable2();
|
|
844
|
+
for (const column of addedColumns.filter((column2) => !existing.data.includes(column2))) {
|
|
845
|
+
const added = await reldb.ddl.addColumn(EXECUTIONS, column, executions[column]);
|
|
846
|
+
if (!added.success) {
|
|
847
|
+
const refreshed = await reldb.schemaColumns(EXECUTIONS);
|
|
848
|
+
if (!refreshed.success || !refreshed.data.includes(column))
|
|
758
849
|
return unavailable2();
|
|
759
850
|
}
|
|
760
851
|
}
|
|
@@ -779,6 +870,36 @@ async function readyStore() {
|
|
|
779
870
|
"hai_runner_concurrency_idx",
|
|
780
871
|
["concurrency_key", "state"]
|
|
781
872
|
],
|
|
873
|
+
[
|
|
874
|
+
EXECUTIONS,
|
|
875
|
+
"hai_runner_parent_idx",
|
|
876
|
+
["parent_id"]
|
|
877
|
+
],
|
|
878
|
+
[
|
|
879
|
+
EXECUTIONS,
|
|
880
|
+
"hai_runner_retention_idx",
|
|
881
|
+
[
|
|
882
|
+
"state",
|
|
883
|
+
"reconciled_at",
|
|
884
|
+
"updated_at",
|
|
885
|
+
"id"
|
|
886
|
+
]
|
|
887
|
+
],
|
|
888
|
+
[
|
|
889
|
+
EXECUTIONS,
|
|
890
|
+
"hai_runner_queue_due_idx",
|
|
891
|
+
["state", "queue_until"]
|
|
892
|
+
],
|
|
893
|
+
[
|
|
894
|
+
EXECUTIONS,
|
|
895
|
+
"hai_runner_lease_due_idx",
|
|
896
|
+
["state", "leased_until"]
|
|
897
|
+
],
|
|
898
|
+
[
|
|
899
|
+
EXECUTIONS,
|
|
900
|
+
"hai_runner_run_due_idx",
|
|
901
|
+
["state", "run_until"]
|
|
902
|
+
],
|
|
782
903
|
[
|
|
783
904
|
EVENTS,
|
|
784
905
|
"hai_runner_event_cursor_idx",
|
|
@@ -789,11 +910,11 @@ async function readyStore() {
|
|
|
789
910
|
if (!result.success) {
|
|
790
911
|
if (reldb.config?.type !== "mysql")
|
|
791
912
|
return unavailable2();
|
|
792
|
-
const
|
|
913
|
+
const existing2 = await reldb.sql.query(
|
|
793
914
|
"SELECT COLUMN_NAME AS column_name FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? ORDER BY SEQ_IN_INDEX",
|
|
794
915
|
[table, name]
|
|
795
916
|
);
|
|
796
|
-
if (!
|
|
917
|
+
if (!existing2.success || JSON.stringify(existing2.data.map((row) => row.column_name)) !== JSON.stringify(fields))
|
|
797
918
|
return unavailable2();
|
|
798
919
|
}
|
|
799
920
|
}
|
|
@@ -801,11 +922,35 @@ async function readyStore() {
|
|
|
801
922
|
const seed = await reldb.sql.execute(`INSERT INTO ${GATE} (id, revision) VALUES (1, 0)${syntax}`);
|
|
802
923
|
return seed.success ? ok(void 0) : unavailable2();
|
|
803
924
|
}
|
|
804
|
-
function makeStore(config, owner) {
|
|
925
|
+
function makeStore(config, owner, ownerProcess) {
|
|
805
926
|
const envById = new Map(config.environments.map((item) => [item.id, item]));
|
|
806
927
|
const envKeys = config.environments.filter((item) => !item.architecture || item.architecture === process.arch).map((item) => hash(item));
|
|
807
928
|
const now = clock();
|
|
808
929
|
const resultValue = reldb.config?.type === "postgresql" ? "CAST(? AS JSONB)" : reldb.config?.type === "mysql" ? "CAST(CONVERT(? USING utf8mb4) AS JSON)" : "?";
|
|
930
|
+
const statementTime = reldb.config?.type === "postgresql" ? "CAST(FLOOR(EXTRACT(EPOCH FROM statement_timestamp()) * 1000) AS BIGINT)" : reldb.config?.type === "mysql" ? "CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS SIGNED)" : now;
|
|
931
|
+
const processJson = ownerProcess ? JSON.stringify(ownerProcess) : null;
|
|
932
|
+
const environmentPath = reldb.json.extract("request_json", "$.environment.id");
|
|
933
|
+
const environmentProjection = reldb.config?.type === "mysql" ? `JSON_UNQUOTE(${environmentPath.sql})` : reldb.config?.type === "postgresql" ? "request_json #>> ?::text[]" : environmentPath.sql;
|
|
934
|
+
async function summary(id2, scopeKey) {
|
|
935
|
+
const row = data(await reldb.sql.get(`
|
|
936
|
+
SELECT id, state, reason, owner_node, owner_generation, result_json, created_at, updated_at, reconciled_at,
|
|
937
|
+
${environmentProjection} AS environment_id
|
|
938
|
+
FROM ${EXECUTIONS} WHERE id = ? AND scope_key = ?`, [...environmentPath.params, id2, scopeKey]));
|
|
939
|
+
return row ? project(row, row.environment_id ?? void 0) : void 0;
|
|
940
|
+
}
|
|
941
|
+
async function parentSettled(sql, row) {
|
|
942
|
+
if (!row.parent_id)
|
|
943
|
+
return true;
|
|
944
|
+
const parent = data(await sql.get(`
|
|
945
|
+
SELECT state, reconciled_at FROM ${EXECUTIONS}
|
|
946
|
+
WHERE id = ? AND owner_node = ? AND owner_generation = ? AND scope_key = ?`, [
|
|
947
|
+
row.parent_id,
|
|
948
|
+
row.owner_node,
|
|
949
|
+
row.owner_generation,
|
|
950
|
+
row.scope_key
|
|
951
|
+
]));
|
|
952
|
+
return !!parent && (parent.state === "unknown" ? parent.reconciled_at !== null : ["succeeded", "failed", "cancelled"].includes(parent.state));
|
|
953
|
+
}
|
|
809
954
|
async function sweep(sql) {
|
|
810
955
|
data(await sql.execute(`UPDATE ${EXECUTIONS} SET state = 'failed', reason = 'queue-timeout', reuse_key = NULL, updated_at = ${now}
|
|
811
956
|
WHERE state = 'queued' AND queue_until <= ${now}`));
|
|
@@ -814,13 +959,51 @@ function makeStore(config, owner) {
|
|
|
814
959
|
data(await sql.execute(`UPDATE ${EXECUTIONS} SET state = 'cancelling', reason = 'run-timeout', updated_at = ${now}
|
|
815
960
|
WHERE state = 'running' AND run_until <= ${now} AND leased_until > ${now}`));
|
|
816
961
|
}
|
|
962
|
+
async function expired(sql, limit) {
|
|
963
|
+
const pages = await Promise.all(RETENTION_STATES.map((state) => {
|
|
964
|
+
const endedAt = state === "unknown" ? "q.reconciled_at" : "q.updated_at";
|
|
965
|
+
return sql.query(`
|
|
966
|
+
SELECT q.id, ${endedAt} AS ended_at FROM ${EXECUTIONS} q
|
|
967
|
+
WHERE q.state = ? AND q.reconciled_at ${state === "unknown" ? "IS NOT NULL" : "IS NULL"}
|
|
968
|
+
AND ${endedAt} <= ${statementTime} - ?
|
|
969
|
+
AND NOT EXISTS (SELECT 1 FROM ${EXECUTIONS} child WHERE child.parent_id = q.id)
|
|
970
|
+
ORDER BY ${endedAt}, q.updated_at, q.id LIMIT ?`, [state, config.retentionMs, limit]);
|
|
971
|
+
}));
|
|
972
|
+
return pages.flatMap((page) => data(page)).sort((left, right) => Number(left.ended_at) - Number(right.ended_at)).slice(0, limit);
|
|
973
|
+
}
|
|
817
974
|
return {
|
|
818
975
|
async sweep() {
|
|
976
|
+
const due = await reldb.sql.get(`
|
|
977
|
+
SELECT id FROM ${EXECUTIONS} WHERE (state = 'queued' AND queue_until <= ${statementTime})
|
|
978
|
+
OR (state IN ('running', 'cancelling') AND leased_until <= ${statementTime})
|
|
979
|
+
OR (state = 'running' AND run_until <= ${statementTime}) LIMIT 1`);
|
|
980
|
+
if (!due.success)
|
|
981
|
+
return unavailable2();
|
|
982
|
+
if (!due.data)
|
|
983
|
+
return ok(void 0);
|
|
819
984
|
return serialized(async (sql) => {
|
|
820
985
|
await sweep(sql);
|
|
821
986
|
return ok(void 0);
|
|
822
987
|
});
|
|
823
988
|
},
|
|
989
|
+
async prune() {
|
|
990
|
+
try {
|
|
991
|
+
if (!(await expired(reldb.sql, 1)).length)
|
|
992
|
+
return ok(0);
|
|
993
|
+
} catch {
|
|
994
|
+
return unavailable2();
|
|
995
|
+
}
|
|
996
|
+
return serialized(async (sql) => {
|
|
997
|
+
const entries = await expired(sql, RETENTION_BATCH);
|
|
998
|
+
if (!entries.length)
|
|
999
|
+
return ok(0);
|
|
1000
|
+
const ids = entries.map((entry) => entry.id);
|
|
1001
|
+
const keys = ids.map(() => "?").join(", ");
|
|
1002
|
+
data(await sql.execute(`DELETE FROM ${EVENTS} WHERE execution_id IN (${keys})`, ids));
|
|
1003
|
+
const removed = data(await sql.execute(`DELETE FROM ${EXECUTIONS} WHERE id IN (${keys})`, ids));
|
|
1004
|
+
return ok(removed.changes);
|
|
1005
|
+
});
|
|
1006
|
+
},
|
|
824
1007
|
async submit(request, pool) {
|
|
825
1008
|
const scopeKey = hash([request.scope.tenantId, request.scope.userId]);
|
|
826
1009
|
const id2 = hash([scopeKey, request.kind, request.idempotencyKey ?? randomUUID()]);
|
|
@@ -841,7 +1024,7 @@ function makeStore(config, owner) {
|
|
|
841
1024
|
return serialized(async (sql) => {
|
|
842
1025
|
const existing = await find(sql, id2, scopeKey) ?? (reuse ? data(await sql.get(`SELECT * FROM ${EXECUTIONS} WHERE reuse_key = ?`, [reuse])) : null);
|
|
843
1026
|
if (existing) {
|
|
844
|
-
return existing.request_key === requestKey ? ok(
|
|
1027
|
+
return existing.request_key === requestKey ? ok(projectRequest(existing)) : conflict();
|
|
845
1028
|
}
|
|
846
1029
|
const environment2 = request.environmentId ? envById.get(request.environmentId) : void 0;
|
|
847
1030
|
if (request.warmPool && (!pool || !environment2))
|
|
@@ -902,22 +1085,21 @@ function makeStore(config, owner) {
|
|
|
902
1085
|
const row = await find(sql, id2, scopeKey);
|
|
903
1086
|
if (!row)
|
|
904
1087
|
throw new Error("Newly inserted execution could not be read");
|
|
905
|
-
return ok(
|
|
1088
|
+
return ok(projectRequest(row));
|
|
906
1089
|
});
|
|
907
1090
|
},
|
|
908
1091
|
async find(scope, kind, key) {
|
|
909
1092
|
try {
|
|
910
1093
|
const scopeKey = hash([scope.tenantId, scope.userId]);
|
|
911
|
-
|
|
912
|
-
return ok(row ? project(row) : void 0);
|
|
1094
|
+
return ok(await summary(hash([scopeKey, kind, key]), scopeKey));
|
|
913
1095
|
} catch {
|
|
914
1096
|
return unavailable2();
|
|
915
1097
|
}
|
|
916
1098
|
},
|
|
917
1099
|
async get(scope, id2) {
|
|
918
1100
|
try {
|
|
919
|
-
const
|
|
920
|
-
return
|
|
1101
|
+
const found = await summary(id2, hash([scope.tenantId, scope.userId]));
|
|
1102
|
+
return found ? ok(found) : missing();
|
|
921
1103
|
} catch {
|
|
922
1104
|
return unavailable2();
|
|
923
1105
|
}
|
|
@@ -932,7 +1114,7 @@ function makeStore(config, owner) {
|
|
|
932
1114
|
data(await sql.execute(`UPDATE ${EXECUTIONS} SET state = 'cancelled', reason = 'parent-ended', updated_at = ${now} WHERE parent_id = ? AND state = 'queued'`, [id2]));
|
|
933
1115
|
data(await sql.execute(`UPDATE ${EXECUTIONS} SET state = 'cancelling', reason = 'parent-ended', updated_at = ${now} WHERE parent_id = ? AND state = 'running'`, [id2]));
|
|
934
1116
|
const changed = await find(sql, id2, row.scope_key);
|
|
935
|
-
return changed ? ok(
|
|
1117
|
+
return changed ? ok(projectRequest(changed)) : missing();
|
|
936
1118
|
});
|
|
937
1119
|
},
|
|
938
1120
|
async reserveWarm(pool) {
|
|
@@ -944,7 +1126,8 @@ function makeStore(config, owner) {
|
|
|
944
1126
|
environment: environment2
|
|
945
1127
|
});
|
|
946
1128
|
const key = hash([pool.kind, pool.key]);
|
|
947
|
-
|
|
1129
|
+
const usage = columns(environment2.resources);
|
|
1130
|
+
async function canReserve(sql) {
|
|
948
1131
|
const [totalsResult, reservedResult] = await Promise.all([
|
|
949
1132
|
sql.get(`
|
|
950
1133
|
SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN state = 'running' AND pool_fingerprint = ?
|
|
@@ -959,9 +1142,8 @@ function makeStore(config, owner) {
|
|
|
959
1142
|
const totals = data(totalsResult);
|
|
960
1143
|
const reserved = data(reservedResult);
|
|
961
1144
|
if (!totals || !reserved || Number(totals.warm) >= pool.minReady || Number(totals.total) >= Math.min(pool.maxTotal, Number(totals.ceiling)) || Number(reserved.active) >= config.toolMaxRunning) {
|
|
962
|
-
return
|
|
1145
|
+
return false;
|
|
963
1146
|
}
|
|
964
|
-
const usage = columns(environment2.resources);
|
|
965
1147
|
const capacity = columns(config.toolCapacity);
|
|
966
1148
|
const occupied = [
|
|
967
1149
|
Number(reserved.cpu),
|
|
@@ -969,7 +1151,16 @@ function makeStore(config, owner) {
|
|
|
969
1151
|
Number(reserved.pids),
|
|
970
1152
|
Number(reserved.scratch)
|
|
971
1153
|
];
|
|
972
|
-
|
|
1154
|
+
return !usage.some((amount, index) => amount + occupied[index] > capacity[index]);
|
|
1155
|
+
}
|
|
1156
|
+
try {
|
|
1157
|
+
if (!await canReserve(reldb.sql))
|
|
1158
|
+
return ok(null);
|
|
1159
|
+
} catch {
|
|
1160
|
+
return unavailable2();
|
|
1161
|
+
}
|
|
1162
|
+
return serialized(async (sql) => {
|
|
1163
|
+
if (!await canReserve(sql))
|
|
973
1164
|
return ok(null);
|
|
974
1165
|
const id2 = hash([
|
|
975
1166
|
owner.generation,
|
|
@@ -1014,12 +1205,25 @@ function makeStore(config, owner) {
|
|
|
1014
1205
|
return result.success && result.data.changes === 1 ? ok(void 0) : lost();
|
|
1015
1206
|
},
|
|
1016
1207
|
async claim(kinds, category, pools = [], warmIds = []) {
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1208
|
+
if (!kinds.length)
|
|
1209
|
+
return ok(null);
|
|
1210
|
+
const capacityClass = category === "tool" ? "tool" : "default";
|
|
1211
|
+
const parentFilter = category === "control" ? "IS NOT NULL" : "IS NULL";
|
|
1212
|
+
const queued = await reldb.sql.get(`
|
|
1213
|
+
SELECT id FROM ${EXECUTIONS} WHERE state = 'queued' AND capacity_class = ? AND parent_id ${parentFilter}
|
|
1214
|
+
AND queue_until > ${now} AND kind IN (${kinds.map(() => "?").join(",")})
|
|
1215
|
+
AND (pinned_node IS NULL OR pinned_node = ?) AND (pinned_generation IS NULL OR pinned_generation = ?) LIMIT 1`, [
|
|
1216
|
+
capacityClass,
|
|
1217
|
+
...kinds,
|
|
1218
|
+
owner.nodeId,
|
|
1219
|
+
owner.generation
|
|
1220
|
+
]);
|
|
1221
|
+
if (!queued.success)
|
|
1222
|
+
return unavailable2();
|
|
1223
|
+
if (!queued.data)
|
|
1224
|
+
return ok(null);
|
|
1225
|
+
async function eligible(sql, includeRequest) {
|
|
1020
1226
|
const maxRunning = category === "tool" ? config.toolMaxRunning : category === "control" ? config.maxControlRunning : config.maxRunning;
|
|
1021
|
-
const capacityClass = category === "tool" ? "tool" : "default";
|
|
1022
|
-
const parentFilter = category === "control" ? "IS NOT NULL" : "IS NULL";
|
|
1023
1227
|
const reserved = data(await sql.get(
|
|
1024
1228
|
`SELECT COUNT(*) AS active, COALESCE(SUM(cpu_milli),0) AS cpu, COALESCE(SUM(memory_mib),0) AS memory,
|
|
1025
1229
|
COALESCE(SUM(pids),0) AS pids, COALESCE(SUM(scratch_mib),0) AS scratch
|
|
@@ -1027,7 +1231,7 @@ function makeStore(config, owner) {
|
|
|
1027
1231
|
[owner.nodeId, capacityClass]
|
|
1028
1232
|
));
|
|
1029
1233
|
if (!reserved)
|
|
1030
|
-
return
|
|
1234
|
+
return null;
|
|
1031
1235
|
const fingerprints = category === "tool" ? pools.flatMap((pool) => {
|
|
1032
1236
|
const environment2 = envById.get(pool.environmentId);
|
|
1033
1237
|
return environment2 ? [
|
|
@@ -1043,8 +1247,8 @@ function makeStore(config, owner) {
|
|
|
1043
1247
|
const canStartCold = Number(reserved.active) < maxRunning;
|
|
1044
1248
|
const eligibleEnvironment = envKeys.length ? ` OR q.environment_key IN (${envKeys.map(() => "?").join(",")})` : "";
|
|
1045
1249
|
const [cpu, memory, pids, scratch] = columns(category === "tool" ? config.toolCapacity : config.capacity);
|
|
1046
|
-
|
|
1047
|
-
SELECT q
|
|
1250
|
+
return data(await sql.get(`
|
|
1251
|
+
SELECT q.id${includeRequest ? ", q.kind, q.request_json, q.parent_id, q.pool_fingerprint" : ""} FROM ${EXECUTIONS} q
|
|
1048
1252
|
WHERE q.state = 'queued' AND q.capacity_class = ? AND q.queue_until > ${now} AND q.parent_id ${parentFilter}
|
|
1049
1253
|
AND (q.pinned_node IS NULL OR q.pinned_node = ?) AND (q.pinned_generation IS NULL OR q.pinned_generation = ?)
|
|
1050
1254
|
AND q.kind IN (${kinds.map(() => "?").join(",")}) AND (q.environment_key IS NULL${eligibleEnvironment})
|
|
@@ -1083,6 +1287,15 @@ function makeStore(config, owner) {
|
|
|
1083
1287
|
scratch - Number(reserved.scratch),
|
|
1084
1288
|
config.maxRunningPerScope
|
|
1085
1289
|
]));
|
|
1290
|
+
}
|
|
1291
|
+
try {
|
|
1292
|
+
if (!await eligible(reldb.sql, false))
|
|
1293
|
+
return ok(null);
|
|
1294
|
+
} catch {
|
|
1295
|
+
return unavailable2();
|
|
1296
|
+
}
|
|
1297
|
+
return serialized(async (sql) => {
|
|
1298
|
+
const row = await eligible(sql, true);
|
|
1086
1299
|
if (!row)
|
|
1087
1300
|
return ok(null);
|
|
1088
1301
|
const request = databaseValue(row.request_json);
|
|
@@ -1090,11 +1303,12 @@ function makeStore(config, owner) {
|
|
|
1090
1303
|
SELECT * FROM ${EXECUTIONS} WHERE id IN (${warmIds.map(() => "?").join(",")}) AND pool_fingerprint = ?
|
|
1091
1304
|
AND state = 'running' AND warm_state = 'ready' AND leased_until > ${now} ORDER BY created_at, id LIMIT 1`, [...warmIds, row.pool_fingerprint])) : null;
|
|
1092
1305
|
const changed = data(await sql.execute(`UPDATE ${EXECUTIONS} SET state = 'running', owner_node = ?, owner_generation = ?,
|
|
1093
|
-
container_json = ${resultValue}, leased_until = ${now} + ?, run_until = ${now} + ?, updated_at = ${now}
|
|
1306
|
+
container_json = ${resultValue}, owner_process_json = ${resultValue}, leased_until = ${now} + ?, run_until = ${now} + ?, updated_at = ${now}
|
|
1094
1307
|
WHERE id = ? AND state = 'queued' AND queue_until > ${now}`, [
|
|
1095
1308
|
owner.nodeId,
|
|
1096
1309
|
owner.generation,
|
|
1097
1310
|
standby?.container_json ? JSON.stringify(databaseValue(standby.container_json)) : null,
|
|
1311
|
+
request.environment ? null : processJson,
|
|
1098
1312
|
config.leaseMs,
|
|
1099
1313
|
request.runTimeoutMs,
|
|
1100
1314
|
row.id
|
|
@@ -1134,11 +1348,20 @@ function makeStore(config, owner) {
|
|
|
1134
1348
|
const row = await find(reldb.sql, id2, hash([scope.tenantId, scope.userId]));
|
|
1135
1349
|
if (!row)
|
|
1136
1350
|
return missing();
|
|
1137
|
-
if (row.state !== "unknown" || row.owner_node !== owner.nodeId || row.reconciled_at !== null || !row.owner_generation
|
|
1351
|
+
if (row.state !== "unknown" || row.owner_node !== owner.nodeId || row.reconciled_at !== null || !row.owner_generation) {
|
|
1138
1352
|
return conflict();
|
|
1139
1353
|
}
|
|
1140
|
-
|
|
1141
|
-
|
|
1354
|
+
if (row.container_json) {
|
|
1355
|
+
const container = databaseValue(row.container_json);
|
|
1356
|
+
return container.engine === config.engine ? ok({ container, generation: row.owner_generation }) : conflict();
|
|
1357
|
+
}
|
|
1358
|
+
if (row.environment_key !== null || !row.owner_process_json || !await parentSettled(reldb.sql, row))
|
|
1359
|
+
return conflict();
|
|
1360
|
+
return ok({
|
|
1361
|
+
container: void 0,
|
|
1362
|
+
process: databaseValue(row.owner_process_json),
|
|
1363
|
+
generation: row.owner_generation
|
|
1364
|
+
});
|
|
1142
1365
|
} catch {
|
|
1143
1366
|
return unavailable2();
|
|
1144
1367
|
}
|
|
@@ -1146,10 +1369,16 @@ function makeStore(config, owner) {
|
|
|
1146
1369
|
async reconcile(scope, id2, generation, review) {
|
|
1147
1370
|
return serialized(async (sql) => {
|
|
1148
1371
|
const scopeKey = hash([scope.tenantId, scope.userId]);
|
|
1372
|
+
if (review.hostResourcesStopped) {
|
|
1373
|
+
const row2 = await find(sql, id2, scopeKey);
|
|
1374
|
+
if (!row2 || !await parentSettled(sql, row2))
|
|
1375
|
+
return conflict();
|
|
1376
|
+
}
|
|
1377
|
+
const proof = review.hostResourcesStopped ? "container_json IS NULL AND environment_key IS NULL AND owner_process_json IS NOT NULL" : "container_json IS NOT NULL";
|
|
1149
1378
|
const result = data(await sql.execute(`UPDATE ${EXECUTIONS}
|
|
1150
1379
|
SET reconciled_at = ${now}, reconciliation_json = ${resultValue}, reuse_key = NULL, updated_at = ${now}
|
|
1151
1380
|
WHERE id = ? AND scope_key = ? AND owner_node = ? AND owner_generation = ? AND state = 'unknown'
|
|
1152
|
-
AND reconciled_at IS NULL AND
|
|
1381
|
+
AND reconciled_at IS NULL AND ${proof}`, [
|
|
1153
1382
|
JSON.stringify(review),
|
|
1154
1383
|
id2,
|
|
1155
1384
|
scopeKey,
|
|
@@ -1159,7 +1388,7 @@ function makeStore(config, owner) {
|
|
|
1159
1388
|
if (result.changes !== 1)
|
|
1160
1389
|
return conflict();
|
|
1161
1390
|
const row = await find(sql, id2, scopeKey);
|
|
1162
|
-
return row ? ok(
|
|
1391
|
+
return row ? ok(projectRequest(row)) : missing();
|
|
1163
1392
|
});
|
|
1164
1393
|
},
|
|
1165
1394
|
async renew(id2) {
|
|
@@ -1190,10 +1419,18 @@ function makeStore(config, owner) {
|
|
|
1190
1419
|
return ok(row.data.state);
|
|
1191
1420
|
},
|
|
1192
1421
|
async emit(id2, value) {
|
|
1422
|
+
const encoded = JSON.stringify({ value });
|
|
1423
|
+
const bytes = Buffer.byteLength(encoded);
|
|
1193
1424
|
const result = await reldb.tx.wrap(async (sql) => {
|
|
1194
|
-
const changed = data(await sql.execute(`UPDATE ${EXECUTIONS} SET event_sequence = event_sequence + 1
|
|
1425
|
+
const changed = data(await sql.execute(`UPDATE ${EXECUTIONS} SET event_sequence = event_sequence + 1, event_bytes = event_bytes + ?
|
|
1195
1426
|
WHERE id = ? AND owner_generation = ? AND state = 'running' AND leased_until > ${now}
|
|
1196
|
-
AND run_until > ${now} AND event_sequence < ?`, [
|
|
1427
|
+
AND run_until > ${now} AND event_sequence < ? AND event_bytes <= ?`, [
|
|
1428
|
+
bytes,
|
|
1429
|
+
id2,
|
|
1430
|
+
owner.generation,
|
|
1431
|
+
config.maxEvents,
|
|
1432
|
+
MAX_EVENT_BYTES - bytes
|
|
1433
|
+
]));
|
|
1197
1434
|
if (changed.changes !== 1)
|
|
1198
1435
|
return lost();
|
|
1199
1436
|
const row = data(await sql.get(`SELECT event_sequence FROM ${EXECUTIONS} WHERE id = ?`, [id2]));
|
|
@@ -1206,7 +1443,7 @@ function makeStore(config, owner) {
|
|
|
1206
1443
|
hash([id2, sequence]),
|
|
1207
1444
|
id2,
|
|
1208
1445
|
sequence,
|
|
1209
|
-
|
|
1446
|
+
encoded,
|
|
1210
1447
|
id2
|
|
1211
1448
|
]));
|
|
1212
1449
|
if (published.changes !== 1)
|
|
@@ -1216,9 +1453,14 @@ function makeStore(config, owner) {
|
|
|
1216
1453
|
return result.success ? result.data : unavailable2();
|
|
1217
1454
|
},
|
|
1218
1455
|
async events(scope, id2, after, limit) {
|
|
1219
|
-
const
|
|
1220
|
-
|
|
1221
|
-
|
|
1456
|
+
const owner2 = await reldb.sql.get(`SELECT id FROM ${EXECUTIONS} WHERE id = ? AND scope_key = ?`, [
|
|
1457
|
+
id2,
|
|
1458
|
+
hash([scope.tenantId, scope.userId])
|
|
1459
|
+
]);
|
|
1460
|
+
if (!owner2.success)
|
|
1461
|
+
return unavailable2();
|
|
1462
|
+
if (!owner2.data)
|
|
1463
|
+
return missing();
|
|
1222
1464
|
const page = await reldb.sql.query(
|
|
1223
1465
|
`SELECT sequence_number, event_json, created_at FROM ${EVENTS}
|
|
1224
1466
|
WHERE execution_id = ? AND sequence_number > ? ORDER BY sequence_number ASC LIMIT ?`,
|
|
@@ -1315,6 +1557,7 @@ function createRunner() {
|
|
|
1315
1557
|
let timer;
|
|
1316
1558
|
let tick;
|
|
1317
1559
|
let sweepAt = 0;
|
|
1560
|
+
let pruneAt = 0;
|
|
1318
1561
|
let unconfirmedCleanup = false;
|
|
1319
1562
|
const running = /* @__PURE__ */ new Map();
|
|
1320
1563
|
const handlers = /* @__PURE__ */ new Map();
|
|
@@ -1379,19 +1622,19 @@ function createRunner() {
|
|
|
1379
1622
|
}
|
|
1380
1623
|
function launch(claim, handler, current, settings, holder, warmed) {
|
|
1381
1624
|
const abort = new AbortController();
|
|
1382
|
-
let
|
|
1625
|
+
let process3;
|
|
1383
1626
|
let uncertain2 = false;
|
|
1384
1627
|
const closeProcess = async () => {
|
|
1385
|
-
const started = await
|
|
1628
|
+
const started = await process3;
|
|
1386
1629
|
if (started?.success)
|
|
1387
1630
|
return (await started.data.close()).success;
|
|
1388
1631
|
return warmed ? (await warmed.close()).success : !uncertain2;
|
|
1389
1632
|
};
|
|
1390
1633
|
async function start(options) {
|
|
1391
1634
|
const { environment: environment2, resources: resources2 } = claim.request;
|
|
1392
|
-
if (abort.signal.aborted ||
|
|
1635
|
+
if (abort.signal.aborted || process3 || !environment2 || !resources2)
|
|
1393
1636
|
return invalid2();
|
|
1394
|
-
|
|
1637
|
+
process3 = (async () => {
|
|
1395
1638
|
const renewed = await current.renew(claim.id);
|
|
1396
1639
|
if (!renewed.success)
|
|
1397
1640
|
return renewed;
|
|
@@ -1405,7 +1648,7 @@ function createRunner() {
|
|
|
1405
1648
|
uncertain2 = true;
|
|
1406
1649
|
return started;
|
|
1407
1650
|
})();
|
|
1408
|
-
return
|
|
1651
|
+
return process3;
|
|
1409
1652
|
}
|
|
1410
1653
|
async function execute2() {
|
|
1411
1654
|
const context = {
|
|
@@ -1484,6 +1727,12 @@ function createRunner() {
|
|
|
1484
1727
|
}
|
|
1485
1728
|
if (closingRequested)
|
|
1486
1729
|
return;
|
|
1730
|
+
if (pruneAt <= performance.now()) {
|
|
1731
|
+
const pruned = await current.prune();
|
|
1732
|
+
if (!pruned.success)
|
|
1733
|
+
logger.warn("Managed execution retention failed", { code: pruned.error.code });
|
|
1734
|
+
pruneAt = performance.now() + (pruned.success && pruned.data ? 250 : 1e3);
|
|
1735
|
+
}
|
|
1487
1736
|
const kinds = [
|
|
1488
1737
|
...handlers.keys()
|
|
1489
1738
|
];
|
|
@@ -1543,9 +1792,10 @@ function createRunner() {
|
|
|
1543
1792
|
generation: randomUUID()
|
|
1544
1793
|
};
|
|
1545
1794
|
config = parsed.data;
|
|
1546
|
-
store = makeStore(config, owner);
|
|
1795
|
+
store = makeStore(config, owner, config.executor ? await readRunnerProcess() : void 0);
|
|
1547
1796
|
unconfirmedCleanup = false;
|
|
1548
1797
|
sweepAt = 0;
|
|
1798
|
+
pruneAt = 0;
|
|
1549
1799
|
warmAt = 0;
|
|
1550
1800
|
if (config.executor) {
|
|
1551
1801
|
timer = setInterval(poll, config.pollMs);
|
|
@@ -1692,7 +1942,7 @@ function createRunner() {
|
|
|
1692
1942
|
const current = store;
|
|
1693
1943
|
if (!current || !config?.executor || closingRequested)
|
|
1694
1944
|
return inactive();
|
|
1695
|
-
if (!validAddress(scope, id2) || !review || typeof review.operatorId !== "string" || !runnerKind.safeParse(review.operatorId).success || typeof review.reason !== "string" || !review.reason.trim() || review.reason.length > 300 || /[\r\n\0]/u.test(review.reason)) {
|
|
1945
|
+
if (!validAddress(scope, id2) || !review || typeof review.operatorId !== "string" || !runnerKind.safeParse(review.operatorId).success || typeof review.reason !== "string" || review.hostResourcesStopped !== void 0 && review.hostResourcesStopped !== true || !review.reason.trim() || review.reason.length > 300 || /[\r\n\0]/u.test(review.reason)) {
|
|
1696
1946
|
return invalid2();
|
|
1697
1947
|
}
|
|
1698
1948
|
if (running.has(id2))
|
|
@@ -1700,14 +1950,19 @@ function createRunner() {
|
|
|
1700
1950
|
const evidence = await current.reconciliation(scope, id2);
|
|
1701
1951
|
if (!evidence.success)
|
|
1702
1952
|
return evidence;
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1953
|
+
if (evidence.data.container) {
|
|
1954
|
+
const verified = await confirmContainerReleased(evidence.data.container);
|
|
1955
|
+
if (!verified.success)
|
|
1956
|
+
return verified;
|
|
1957
|
+
} else if (review.hostResourcesStopped !== true || !await confirmRunnerProcessReleased(evidence.data.process)) {
|
|
1958
|
+
return cleanupUnconfirmed();
|
|
1959
|
+
}
|
|
1706
1960
|
if (current !== store || closingRequested)
|
|
1707
1961
|
return inactive();
|
|
1708
1962
|
const reconciled = await current.reconcile(scope, id2, evidence.data.generation, {
|
|
1709
1963
|
operatorId: review.operatorId,
|
|
1710
|
-
reason: review.reason.trim()
|
|
1964
|
+
reason: review.reason.trim(),
|
|
1965
|
+
...!evidence.data.container && { hostResourcesStopped: true }
|
|
1711
1966
|
});
|
|
1712
1967
|
if (reconciled.success)
|
|
1713
1968
|
logger.info("Unknown execution resources reconciled", { executionId: id2, operatorId: review.operatorId });
|