@coreplane/switchboard 1.254.1 → 1.255.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/dist/assets/.dockerignore +3 -0
- package/dist/assets/Dockerfile +12 -1
- package/dist/assets/config/config.example.yaml +6 -1
- package/dist/assets/deploy/cloudflare/worker.ts +39 -23
- package/dist/assets/deploy/cloudflare-memory/worker.ts +552 -5
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +5 -0
- package/dist/assets/deploy/cloudflare-resident/Dockerfile +13 -1
- package/dist/assets/deploy/cloudflare-resident/levels.ts +84 -0
- package/dist/assets/deploy/cloudflare-resident/prepare-commit-msg +17 -0
- package/dist/assets/deploy/cloudflare-resident/worker.ts +202 -31
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +11 -1
- package/dist/assets/deploy/cloudflare-sandbox/prepare-commit-msg +17 -0
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +21 -4
- package/dist/assets/deploy/hooks/prepare-commit-msg +17 -0
- package/dist/assets/deploy/secrets.manifest.json +12 -0
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +21 -0
- package/dist/assets/src/core/budgets.ts +35 -2
- package/dist/assets/src/core/coordinator/contract.ts +6 -0
- package/dist/assets/src/core/coordinator/driver.ts +49 -6
- package/dist/assets/src/core/costs.ts +39 -16
- package/dist/assets/src/core/pipelineStanding.ts +5 -0
- package/dist/assets/src/core/plane/decide.ts +389 -22
- package/dist/assets/src/core/refusal.ts +3 -0
- package/dist/assets/src/core/reviewVerdict.ts +15 -2
- package/dist/assets/src/core/runEvents.ts +31 -12
- package/dist/assets/src/core/runLedger/sessionLog.ts +128 -0
- package/dist/assets/src/core/runLedger/types.ts +3 -0
- package/dist/assets/src/core/runRecord.ts +24 -7
- package/dist/assets/src/core/ship/coordinator.ts +327 -61
- package/dist/assets/src/core/trace/workerTrace.ts +3 -0
- package/dist/assets/src/execution/sandboxErrors.ts +77 -6
- package/dist/assets/web/dist/.vite/manifest.json +59 -58
- package/dist/assets/web/dist/assets/CostsPage-BaeWnm-o.js +1 -0
- package/dist/assets/web/dist/assets/{DeliveryPage-ngPsO2to.js → DeliveryPage-BBAyLwPq.js} +1 -1
- package/dist/assets/web/dist/assets/{HomePage-DvxTHzPx.js → HomePage-Be7jLLnU.js} +1 -1
- package/dist/assets/web/dist/assets/PendingTurnRow-DGINv9XT.js +1 -0
- package/dist/assets/web/dist/assets/{PlanePage-DpWfiX4C.js → PlanePage-JEj-lqgz.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-DG86v39Y.js → ResidentDetailPage-D_RD6wLo.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-x6p689VH.js → ResidentsIndexPage-BLkSuCxo.js} +1 -1
- package/dist/assets/web/dist/assets/RunFoldRow-CfuZqf_O.js +1 -0
- package/dist/assets/web/dist/assets/{RunRoutePage-ysJBY8xQ.js → RunRoutePage-CmWYGR36.js} +4 -4
- package/dist/assets/web/dist/assets/{RunsIndexPage-CuzFchcn.js → RunsIndexPage-DW-HHuZa.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-BuLmfcbG.js → ScheduledPage-3aYsDf-q.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-BujWkdU_.js → SettingsPage-DG-p5Xy1.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-C8Bc0pTX.js → StatusDot-CEnGlyAL.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-BWwJx27K.js → Tooltip-CiunVowT.js} +1 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-iOYnmTcQ.js +1 -0
- package/dist/assets/web/dist/assets/budgets-c1eumrqD.js +1 -0
- package/dist/assets/web/dist/assets/{dist-CpnyQGOb.js → dist-luhv3YSo.js} +1 -1
- package/dist/assets/web/dist/assets/indexRow-DborJPFp.js +1 -0
- package/dist/assets/web/dist/assets/{main-Comxmwi4.js → main-mAKx_zo9.js} +2 -2
- package/dist/assets/web/dist/assets/{sseReplay-IzTdD4-3.js → sseReplay-DE6wv1Ua.js} +6 -6
- package/dist/cli.js +6022 -4956
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +0 -1
- package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +0 -1
- package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +0 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-B9kjA1AT.js +0 -1
- package/dist/assets/web/dist/assets/budgets-CbIyPAER.js +0 -1
- package/dist/assets/web/dist/assets/indexRow-DABQtONT.js +0 -1
|
@@ -77,15 +77,21 @@ import {
|
|
|
77
77
|
reclaimPhase,
|
|
78
78
|
selectReclaim,
|
|
79
79
|
} from "../../src/core/runLedger/decisions.ts";
|
|
80
|
-
import { intakeReceiptRetentionMs } from "../../src/core/budgets.ts";
|
|
80
|
+
import { intakeReceiptRetentionMs, minutesToMs, PLANE } from "../../src/core/budgets.ts";
|
|
81
81
|
import {
|
|
82
82
|
decide,
|
|
83
|
+
effectCapRefusal,
|
|
84
|
+
planeAskAnswerOf,
|
|
83
85
|
planeAskWordOf,
|
|
86
|
+
RESIDENT_DRAIN_WINDOW,
|
|
87
|
+
type PlaneAskAnswer,
|
|
88
|
+
type PlaneLevelRow,
|
|
84
89
|
type PlaneAckOutcome,
|
|
85
90
|
type PlaneEffect,
|
|
86
91
|
type PlaneEvent,
|
|
87
92
|
type PlaneOutcomePost,
|
|
88
93
|
type PlaneQueueRow,
|
|
94
|
+
type PlaneReservation,
|
|
89
95
|
type PlaneStage,
|
|
90
96
|
type PlaneState,
|
|
91
97
|
type PlaneWrite,
|
|
@@ -220,6 +226,11 @@ export interface Env {
|
|
|
220
226
|
/** The dataset's name, rendered beside the binding, so `/healthz` can answer
|
|
221
227
|
* `runMetrics:<dataset>` and the bot's boot probe can compare names. */
|
|
222
228
|
RUN_METRICS_DATASET?: string;
|
|
229
|
+
/** The bot Worker, for the plane's effect push (record 0064, "Where it
|
|
230
|
+
* lives"): committed effects are POSTed to its bearer-gated
|
|
231
|
+
* `/plane/effects`, which forwards to the container. Optional like
|
|
232
|
+
* SHIP_COORDINATOR — without it effects ride the heartbeat answers alone. */
|
|
233
|
+
BOT?: Fetcher;
|
|
223
234
|
MEMORY_TOKEN?: string;
|
|
224
235
|
}
|
|
225
236
|
|
|
@@ -1777,6 +1788,22 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1777
1788
|
* dispatch it describes claimed the thread must not read its own claim as
|
|
1778
1789
|
* "thread live" (orchestration-plane item 8). */
|
|
1779
1790
|
private planeState(excludeRunId?: string): PlaneState {
|
|
1791
|
+
const now = systemClock();
|
|
1792
|
+
// A reservation not yet promoted into a live row expires after its window:
|
|
1793
|
+
// a dispatch that died between the admission answer and its claim must not
|
|
1794
|
+
// hold the thread forever. Promotion deletes the row (the live row holds
|
|
1795
|
+
// the thread from there), so age alone is the test.
|
|
1796
|
+
const reservations = this.sql
|
|
1797
|
+
.exec<{ kind: string; key: string; run_id: string; at: number }>(
|
|
1798
|
+
`SELECT * FROM plane_reservations WHERE kind = 'thread' AND at > ?`,
|
|
1799
|
+
now - minutesToMs(PLANE.reservationMinutes),
|
|
1800
|
+
)
|
|
1801
|
+
.toArray()
|
|
1802
|
+
.map((r): PlaneReservation => ({ kind: "thread", key: r.key, runId: r.run_id, at: r.at }));
|
|
1803
|
+
const openWindows = this.sql
|
|
1804
|
+
.exec<{ kind: string }>(`SELECT kind FROM plane_windows WHERE phase = 'open'`)
|
|
1805
|
+
.toArray()
|
|
1806
|
+
.map((r) => r.kind);
|
|
1780
1807
|
const queue = this.sql
|
|
1781
1808
|
.exec<{
|
|
1782
1809
|
run_id: string;
|
|
@@ -1805,7 +1832,19 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1805
1832
|
.exec<{ thread_key: string }>(`SELECT thread_key FROM live_runs WHERE run_id IS NOT ?`, excludeRunId ?? null)
|
|
1806
1833
|
.toArray()
|
|
1807
1834
|
.map((r) => r.thread_key);
|
|
1808
|
-
|
|
1835
|
+
const levels = this.sql
|
|
1836
|
+
.exec<{ resident: string; name: string; side: string; reported_at: number; generation: string }>(
|
|
1837
|
+
`SELECT * FROM plane_levels`,
|
|
1838
|
+
)
|
|
1839
|
+
.toArray()
|
|
1840
|
+
.map((r): PlaneLevelRow => ({
|
|
1841
|
+
resident: r.resident,
|
|
1842
|
+
name: r.name as PlaneLevelRow["name"],
|
|
1843
|
+
side: r.side as PlaneLevelRow["side"],
|
|
1844
|
+
reportedAt: r.reported_at,
|
|
1845
|
+
generation: r.generation,
|
|
1846
|
+
}));
|
|
1847
|
+
return { queue, liveThreads, reservations, openWindows, levels };
|
|
1809
1848
|
}
|
|
1810
1849
|
|
|
1811
1850
|
/** The decider's writes, applied inside the same `transactionSync` that read
|
|
@@ -1829,7 +1868,67 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1829
1868
|
);
|
|
1830
1869
|
} else if (w.table === "plane_queue" && w.op === "state") {
|
|
1831
1870
|
this.sql.exec(`UPDATE plane_queue SET state = ? WHERE run_id = ?`, w.state, w.runId);
|
|
1871
|
+
} else if (w.table === "plane_reservations" && w.op === "put") {
|
|
1872
|
+
this.sql.exec(
|
|
1873
|
+
`INSERT OR REPLACE INTO plane_reservations (kind, key, run_id, at) VALUES (?, ?, ?, ?)`,
|
|
1874
|
+
w.row.kind,
|
|
1875
|
+
w.row.key,
|
|
1876
|
+
w.row.runId,
|
|
1877
|
+
w.row.at,
|
|
1878
|
+
);
|
|
1879
|
+
} else if (w.table === "plane_reservations" && w.op === "del") {
|
|
1880
|
+
this.sql.exec(`DELETE FROM plane_reservations WHERE kind = 'thread' AND key = ?`, w.key);
|
|
1881
|
+
} else if (w.table === "plane_windows" && w.op === "put") {
|
|
1882
|
+
this.sql.exec(
|
|
1883
|
+
`INSERT OR REPLACE INTO plane_windows (kind, key, phase, opened_at, reason_json) VALUES (?, ?, 'open', ?, '{}')`,
|
|
1884
|
+
w.window,
|
|
1885
|
+
w.window,
|
|
1886
|
+
w.at,
|
|
1887
|
+
);
|
|
1888
|
+
} else if (w.table === "plane_windows" && w.op === "del") {
|
|
1889
|
+
this.sql.exec(`DELETE FROM plane_windows WHERE kind = ?`, w.window);
|
|
1890
|
+
} else if (w.table === "plane_levels") {
|
|
1891
|
+
this.sql.exec(
|
|
1892
|
+
`INSERT OR REPLACE INTO plane_levels (resident, name, side, reported_at, generation) VALUES (?, ?, ?, ?, ?)`,
|
|
1893
|
+
w.row.resident,
|
|
1894
|
+
w.row.name,
|
|
1895
|
+
w.row.side,
|
|
1896
|
+
w.row.reportedAt,
|
|
1897
|
+
w.row.generation,
|
|
1898
|
+
);
|
|
1832
1899
|
} else {
|
|
1900
|
+
// The effect bounds (record 0064): an offer past the per-run or total
|
|
1901
|
+
// cap is refused by the cap's name — the throw aborts the transaction,
|
|
1902
|
+
// so the queue row stays waiting and the next event re-decides.
|
|
1903
|
+
const total = Number(
|
|
1904
|
+
this.sql.exec<{ n: number }>(`SELECT COUNT(*) AS n FROM plane_effects WHERE acked_at IS NULL`).toArray()[0]
|
|
1905
|
+
?.n ?? 0,
|
|
1906
|
+
);
|
|
1907
|
+
const forRun =
|
|
1908
|
+
w.effect.kind === "admit"
|
|
1909
|
+
? Number(
|
|
1910
|
+
this.sql
|
|
1911
|
+
.exec<{
|
|
1912
|
+
n: number;
|
|
1913
|
+
}>(
|
|
1914
|
+
// Exact id matching (`admit:<runId>`): the seal runs with bot-minted
|
|
1915
|
+
// run ids, and a LIKE would read `%`/`_` in one as wildcards.
|
|
1916
|
+
`SELECT COUNT(*) AS n FROM plane_effects WHERE acked_at IS NULL AND id = 'admit:' || ?`,
|
|
1917
|
+
w.effect.runId,
|
|
1918
|
+
)
|
|
1919
|
+
.toArray()[0]?.n ?? 0,
|
|
1920
|
+
)
|
|
1921
|
+
: 0;
|
|
1922
|
+
const refusal = effectCapRefusal({ total, forRun }, w.effect);
|
|
1923
|
+
if (refusal !== undefined) throw new Error(refusal);
|
|
1924
|
+
// A re-offer lands after an ack for any kind — a probe re-probes after
|
|
1925
|
+
// its ack (one open probe per resident, record 0064), and an observation
|
|
1926
|
+
// re-enters an admitted run whose acked `admit:<runId>` row would
|
|
1927
|
+
// otherwise swallow the walk's re-offer, stranding the run `admitted`
|
|
1928
|
+
// with no effect delivered. The decider only re-emits an effect when
|
|
1929
|
+
// its subject is waiting again, and the bot's own getById dedup guards
|
|
1930
|
+
// a genuine duplicate admit, so the acked row is history, not a fence.
|
|
1931
|
+
this.sql.exec(`DELETE FROM plane_effects WHERE id = ? AND acked_at IS NOT NULL`, w.effect.id);
|
|
1833
1932
|
// An offer keeps its first `offered_at`: a re-decided admit after a
|
|
1834
1933
|
// roll is the same effect, not a younger one.
|
|
1835
1934
|
this.sql.exec(
|
|
@@ -1838,6 +1937,22 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1838
1937
|
JSON.stringify(w.effect),
|
|
1839
1938
|
w.at,
|
|
1840
1939
|
);
|
|
1940
|
+
// The admitted run's attaching row, in the same transaction as the
|
|
1941
|
+
// effect (record 0064, "The queue"): owner `plane`, lease already
|
|
1942
|
+
// expired, request in the meta — exactly the row a reserved run whose
|
|
1943
|
+
// owner died leaves (run-history item 42), so the bot's reclaim sweep
|
|
1944
|
+
// restarts it from the stored request under this id.
|
|
1945
|
+
if (w.effect.kind === "admit") {
|
|
1946
|
+
this.sql.exec(
|
|
1947
|
+
`INSERT OR IGNORE INTO live_runs (run_id, thread_key, owner_gen, lease_until, started_at, phase, stop, meta_json, card_json, system_text, tools_json, state_json)
|
|
1948
|
+
VALUES (?, ?, 'plane', ?, ?, 'attaching', NULL, ?, NULL, '', '[]', '{}')`,
|
|
1949
|
+
w.effect.runId,
|
|
1950
|
+
w.effect.threadKey,
|
|
1951
|
+
w.at,
|
|
1952
|
+
w.at,
|
|
1953
|
+
JSON.stringify({ request: w.effect.request }),
|
|
1954
|
+
);
|
|
1955
|
+
}
|
|
1841
1956
|
}
|
|
1842
1957
|
}
|
|
1843
1958
|
}
|
|
@@ -1851,9 +1966,200 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1851
1966
|
this.applyPlaneWrites(decision.writes);
|
|
1852
1967
|
effects = decision.effects;
|
|
1853
1968
|
});
|
|
1969
|
+
this.pushPlaneEffects(effects);
|
|
1854
1970
|
return { effects };
|
|
1855
1971
|
}
|
|
1856
1972
|
|
|
1973
|
+
/** The admission ask (`POST /plane/admit`, record 0064 "The queue"): one
|
|
1974
|
+
* transaction decides and writes — `admitted` reserves the thread,
|
|
1975
|
+
* `queued` stores the request under the minted id. */
|
|
1976
|
+
planeAdmit(
|
|
1977
|
+
post: {
|
|
1978
|
+
runId: string;
|
|
1979
|
+
requester: string;
|
|
1980
|
+
threadKey: string;
|
|
1981
|
+
request: Record<string, unknown>;
|
|
1982
|
+
stage?: PlaneStage;
|
|
1983
|
+
resident?: string;
|
|
1984
|
+
restartOf?: boolean;
|
|
1985
|
+
reaskMs?: number;
|
|
1986
|
+
},
|
|
1987
|
+
now: number,
|
|
1988
|
+
): PlaneAskAnswer {
|
|
1989
|
+
let answer!: PlaneAskAnswer;
|
|
1990
|
+
this.ctx.storage.transactionSync(() => {
|
|
1991
|
+
if (post.reaskMs !== undefined)
|
|
1992
|
+
this.sql.exec(`INSERT OR REPLACE INTO meta (key, value) VALUES ('plane_reask_ms', ?)`, String(post.reaskMs));
|
|
1993
|
+
const decision = decide(this.planeState(), {
|
|
1994
|
+
kind: "ask",
|
|
1995
|
+
at: now,
|
|
1996
|
+
runId: post.runId,
|
|
1997
|
+
requester: post.requester,
|
|
1998
|
+
threadKey: post.threadKey,
|
|
1999
|
+
stage: post.stage ?? "admission",
|
|
2000
|
+
request: post.request,
|
|
2001
|
+
...(post.resident !== undefined ? { resident: post.resident } : {}),
|
|
2002
|
+
...(post.restartOf !== undefined ? { restartOf: post.restartOf } : {}),
|
|
2003
|
+
});
|
|
2004
|
+
this.applyPlaneWrites(decision.writes);
|
|
2005
|
+
answer = planeAskAnswerOf(decision, post.runId);
|
|
2006
|
+
});
|
|
2007
|
+
if (answer.kind === "queued") void this.ensurePlaneReaskAlarm(now);
|
|
2008
|
+
console.log(
|
|
2009
|
+
`[plane/admit] ${post.threadKey} → ${answer.kind}${answer.kind === "queued" ? ` position ${answer.position}` : ""} (run ${post.runId})`,
|
|
2010
|
+
);
|
|
2011
|
+
return answer;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
/** A resident's level report (`POST /plane/level`, record 0064): `seat` and `memory`
|
|
2015
|
+
* land as level events — a `below` side walks the queue; the registry's
|
|
2016
|
+
* drain posts land as the resident-drain window's open (`above`) and lift
|
|
2017
|
+
* (`below` — a `cleared` or the alarm's `expired`). */
|
|
2018
|
+
planeLevel(
|
|
2019
|
+
post: { resident: string; name: "seat" | "memory" | "drain"; side: "below" | "above"; generation: string },
|
|
2020
|
+
now: number,
|
|
2021
|
+
): { admitted: number } {
|
|
2022
|
+
const r =
|
|
2023
|
+
post.name === "drain"
|
|
2024
|
+
? this.planeApply({
|
|
2025
|
+
kind: "window",
|
|
2026
|
+
at: now,
|
|
2027
|
+
window: RESIDENT_DRAIN_WINDOW,
|
|
2028
|
+
phase: post.side === "above" ? "opened" : "lifted",
|
|
2029
|
+
})
|
|
2030
|
+
: this.planeApply({
|
|
2031
|
+
kind: "level",
|
|
2032
|
+
at: now,
|
|
2033
|
+
resident: post.resident,
|
|
2034
|
+
name: post.name,
|
|
2035
|
+
side: post.side,
|
|
2036
|
+
generation: post.generation,
|
|
2037
|
+
});
|
|
2038
|
+
const admitted = r.effects.filter((e) => e.kind === "admit").length;
|
|
2039
|
+
console.log(`[plane/level] ${post.resident} ${post.name} ${post.side} — ${admitted} admission(s)`);
|
|
2040
|
+
return { admitted };
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
/** A refusal-by-name the bot met at attach or exec (`POST /plane/observe`,
|
|
2044
|
+
* record 0064): an admitted run re-enters the queue at its old position. */
|
|
2045
|
+
planeObserve(post: { runId: string; resident: string; refusal: string }, now: number): { reentered: boolean } {
|
|
2046
|
+
let reentered = false;
|
|
2047
|
+
this.ctx.storage.transactionSync(() => {
|
|
2048
|
+
const decision = decide(this.planeState(), {
|
|
2049
|
+
kind: "observation",
|
|
2050
|
+
at: now,
|
|
2051
|
+
runId: post.runId,
|
|
2052
|
+
resident: post.resident,
|
|
2053
|
+
refusal: post.refusal,
|
|
2054
|
+
});
|
|
2055
|
+
this.applyPlaneWrites(decision.writes);
|
|
2056
|
+
reentered = decision.writes.length > 0;
|
|
2057
|
+
});
|
|
2058
|
+
if (reentered) void this.ensurePlaneReaskAlarm(now);
|
|
2059
|
+
console.log(
|
|
2060
|
+
`[plane/observe] run ${post.runId} on ${post.resident}: ${post.refusal.slice(0, 60)} — ${reentered ? "re-entered" : "no-op"}`,
|
|
2061
|
+
);
|
|
2062
|
+
return { reentered };
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
/** The re-ask cadence (record 0064): while a queued row waits on a resident,
|
|
2066
|
+
* the object's alarm fires within the cadence — the sweep's own 6 h alarm
|
|
2067
|
+
* is pulled forward, never pushed back. */
|
|
2068
|
+
private planeReaskMs(): number {
|
|
2069
|
+
const row = this.sql.exec<{ value: string }>(`SELECT value FROM meta WHERE key = 'plane_reask_ms'`).toArray()[0];
|
|
2070
|
+
const stored = row ? Number(row.value) : NaN;
|
|
2071
|
+
return Number.isFinite(stored) && stored > 0 ? stored : minutesToMs(PLANE.reaskMinutes);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
private planeWaitsOnResident(): boolean {
|
|
2075
|
+
return this.planeState().queue.some(
|
|
2076
|
+
(r) => r.state === "waiting" && r.conditions.some((c) => c.kind === "seat" || c.kind === "memory"),
|
|
2077
|
+
);
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
private async ensurePlaneReaskAlarm(now: number): Promise<void> {
|
|
2081
|
+
if (!this.planeWaitsOnResident()) return;
|
|
2082
|
+
const due = now + this.planeReaskMs();
|
|
2083
|
+
const set = await this.ctx.storage.getAlarm();
|
|
2084
|
+
if (set === null || set > due) await this.ctx.storage.setAlarm(due);
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/** A window's open or lift over the RPC seam (`/plane/deploy`; a later
|
|
2088
|
+
* unit's `plane window lift`): kind `deploy` is the pending deploy. */
|
|
2089
|
+
planeWindow(window: string, phase: "opened" | "lifted", now: number): { admitted: number } {
|
|
2090
|
+
const r = this.planeApply({ kind: "window", at: now, window, phase });
|
|
2091
|
+
return { admitted: r.effects.length };
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
/** `runs stop` on a queued id (record 0064): the waiting row is withdrawn;
|
|
2095
|
+
* an id the queue does not hold waiting answers false. */
|
|
2096
|
+
planeWithdraw(runId: string, now: number): { withdrawn: boolean } {
|
|
2097
|
+
let withdrawn = false;
|
|
2098
|
+
this.ctx.storage.transactionSync(() => {
|
|
2099
|
+
const decision = decide(this.planeState(), { kind: "withdraw", at: now, runId });
|
|
2100
|
+
this.applyPlaneWrites(decision.writes);
|
|
2101
|
+
withdrawn = decision.writes.length > 0;
|
|
2102
|
+
});
|
|
2103
|
+
return { withdrawn };
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
/** One queued row, for the queued id's page. */
|
|
2107
|
+
planeQueueRowOf(runId: string): PlaneQueueRow | null {
|
|
2108
|
+
return this.planeState().queue.find((r) => r.runId === runId) ?? null;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
/** The seal (record 0064, "The queue"): the run's own open effects are
|
|
2112
|
+
* dropped — an admit for a run that just ended is stale — then the sealed
|
|
2113
|
+
* event frees the thread and walks the queue, all in one transaction. */
|
|
2114
|
+
private planeSealed(runId: string, threadKey: string, now: number): void {
|
|
2115
|
+
let effects: PlaneEffect[] = [];
|
|
2116
|
+
try {
|
|
2117
|
+
this.ctx.storage.transactionSync(() => {
|
|
2118
|
+
// Exact id matching (`admit:<runId>`), never LIKE: a bot-minted run id
|
|
2119
|
+
// can carry `%` or `_`, which a pattern would read as wildcards.
|
|
2120
|
+
this.sql.exec(`DELETE FROM plane_effects WHERE acked_at IS NULL AND id = 'admit:' || ?`, runId);
|
|
2121
|
+
const decision = decide(this.planeState(), { kind: "sealed", at: now, threadKey });
|
|
2122
|
+
this.applyPlaneWrites(decision.writes);
|
|
2123
|
+
effects = decision.effects;
|
|
2124
|
+
});
|
|
2125
|
+
} catch (err) {
|
|
2126
|
+
// A cap refusal here must not undo the finish that already committed.
|
|
2127
|
+
console.warn(`[plane/sealed] ${threadKey}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
this.pushPlaneEffects(effects);
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
/** The transport's push (record 0064, "Where it lives"): committed effects
|
|
2134
|
+
* are pushed to the bot Worker over the service binding, which forwards to
|
|
2135
|
+
* the container. Fire and forget — a push that fails is not retried by a
|
|
2136
|
+
* timer; the effect rides the next heartbeat or reclaim-sweep answer. */
|
|
2137
|
+
private pushPlaneEffects(effects: PlaneEffect[]): void {
|
|
2138
|
+
if (effects.length === 0) return;
|
|
2139
|
+
const bot = this.env.BOT;
|
|
2140
|
+
if (!bot) return;
|
|
2141
|
+
void bot
|
|
2142
|
+
.fetch("https://bot/plane/effects", {
|
|
2143
|
+
method: "POST",
|
|
2144
|
+
headers: {
|
|
2145
|
+
"content-type": "application/json",
|
|
2146
|
+
authorization: `Bearer ${this.env.MEMORY_TOKEN ?? ""}`,
|
|
2147
|
+
},
|
|
2148
|
+
body: JSON.stringify({ effects }),
|
|
2149
|
+
})
|
|
2150
|
+
.then((r) => {
|
|
2151
|
+
if (!r.ok)
|
|
2152
|
+
console.warn(
|
|
2153
|
+
`[plane/push] ${effects.length} effect(s) → bot answered ${r.status} — they ride the next heartbeat`,
|
|
2154
|
+
);
|
|
2155
|
+
})
|
|
2156
|
+
.catch((err: unknown) => {
|
|
2157
|
+
console.warn(
|
|
2158
|
+
`[plane/push] ${effects.length} effect(s) not delivered: ${err instanceof Error ? err.message : String(err)} — they ride the next heartbeat`,
|
|
2159
|
+
);
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
|
|
1857
2163
|
/** The unacknowledged effects, oldest first, at most `PLANE_EFFECTS_PER_ANSWER`
|
|
1858
2164
|
* (orchestration-plane item 7) — what every heartbeat and reclaim answer carries. Public: the
|
|
1859
2165
|
* reclaim route composes it beside the runs it took. */
|
|
@@ -1970,6 +2276,29 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1970
2276
|
return row ? (JSON.parse(row.json) as CoordinatorInstance) : null;
|
|
1971
2277
|
}
|
|
1972
2278
|
|
|
2279
|
+
/** The hard stop's mark on the instance row (record 0060; issue 1924).
|
|
2280
|
+
* Idempotent: a marked row keeps its first mark. */
|
|
2281
|
+
async markInstanceStopped(id: string, at: number): Promise<{ ok: true } | { ok: false; reason: "unknown_instance" }> {
|
|
2282
|
+
let out: { ok: true } | { ok: false; reason: "unknown_instance" } = { ok: true };
|
|
2283
|
+
this.ctx.storage.transactionSync(() => {
|
|
2284
|
+
const row = this.sql
|
|
2285
|
+
.exec<{ json: string }>(`SELECT json FROM coordinator_instances WHERE instance_id = ?`, id)
|
|
2286
|
+
.toArray()[0];
|
|
2287
|
+
if (!row) {
|
|
2288
|
+
out = { ok: false, reason: "unknown_instance" };
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
const instance = JSON.parse(row.json) as CoordinatorInstance;
|
|
2292
|
+
if (instance.stop !== undefined) return;
|
|
2293
|
+
this.sql.exec(
|
|
2294
|
+
`UPDATE coordinator_instances SET json = ? WHERE instance_id = ?`,
|
|
2295
|
+
JSON.stringify({ ...instance, stop: { at } }),
|
|
2296
|
+
id,
|
|
2297
|
+
);
|
|
2298
|
+
});
|
|
2299
|
+
return out;
|
|
2300
|
+
}
|
|
2301
|
+
|
|
1973
2302
|
// ---- the units of the plan an instance runs (run-history item 50) -----------
|
|
1974
2303
|
|
|
1975
2304
|
/** Each row replaced whole under its (instance, unit); a replace keeps the row's place. */
|
|
@@ -2152,6 +2481,10 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2152
2481
|
req,
|
|
2153
2482
|
);
|
|
2154
2483
|
if (!out.ok) return;
|
|
2484
|
+
// The claim promotes the thread's reservation (record 0064, "The queue"):
|
|
2485
|
+
// the live row holds the thread from here, so the reservation row retires
|
|
2486
|
+
// in the same transaction that writes the claim.
|
|
2487
|
+
this.sql.exec(`DELETE FROM plane_reservations WHERE kind = 'thread' AND key = ?`, req.threadKey);
|
|
2155
2488
|
switch (decideClaimWrite(existing, req)) {
|
|
2156
2489
|
case "keep":
|
|
2157
2490
|
return;
|
|
@@ -2359,6 +2692,9 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2359
2692
|
out = { ok: true, stored: put.stored };
|
|
2360
2693
|
});
|
|
2361
2694
|
if (!out.ok) return out;
|
|
2695
|
+
// The seal flips `thread_free` (record 0064, "The queue"): the plane frees
|
|
2696
|
+
// the thread, drops the sealed run's own open effects and walks the queue.
|
|
2697
|
+
this.planeSealed(runId, record.threadKey, systemClock());
|
|
2362
2698
|
// The point after the commit, never inside it (`sendRunFinished`'s placement):
|
|
2363
2699
|
// the finish usually replaces the start tombstone, so this is where most
|
|
2364
2700
|
// runs are counted (run-metrics.md item 2).
|
|
@@ -2376,11 +2712,16 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2376
2712
|
* started. Fenced. */
|
|
2377
2713
|
async abandon(runId: string, gen: string): Promise<FenceResult> {
|
|
2378
2714
|
let out: FenceResult = { ok: true };
|
|
2715
|
+
let threadKey: string | undefined;
|
|
2379
2716
|
this.ctx.storage.transactionSync(() => {
|
|
2380
|
-
|
|
2717
|
+
const row = this.liveRow(runId);
|
|
2718
|
+
out = checkFence(row, gen);
|
|
2381
2719
|
if (!out.ok) return;
|
|
2720
|
+
threadKey = row?.threadKey;
|
|
2382
2721
|
this.deleteLiveRows([runId]);
|
|
2383
2722
|
});
|
|
2723
|
+
// An abandoned reservation seals like a finish does: the thread frees and the queue walks.
|
|
2724
|
+
if (out.ok && threadKey !== undefined) this.planeSealed(runId, threadKey, systemClock());
|
|
2384
2725
|
return out;
|
|
2385
2726
|
}
|
|
2386
2727
|
|
|
@@ -2848,7 +3189,18 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2848
3189
|
console.log(
|
|
2849
3190
|
`[runs/alarm] swept ${deleted} rows outside policy, pruned ${receipts} intake receipt(s), dropped ${dropped} session log(s)`,
|
|
2850
3191
|
);
|
|
3192
|
+
// The plane's re-ask (record 0064): while a queued row waits on a resident
|
|
3193
|
+
// that has said nothing within the cadence, one probe effect per
|
|
3194
|
+
// resident — and the next alarm is pulled forward to the cadence, so a
|
|
3195
|
+
// silent resident is probed, never waited on forever. The sweep rides
|
|
3196
|
+
// the same alarm; a probing cadence re-runs it, which is only indexed
|
|
3197
|
+
// deletes and only while something waits.
|
|
3198
|
+
if (this.planeWaitsOnResident()) {
|
|
3199
|
+
const probes = this.planeApply({ kind: "reask", at: now, cadenceMs: this.planeReaskMs() }).effects;
|
|
3200
|
+
if (probes.length > 0) console.log(`[plane/reask] ${probes.map((e) => e.id).join(", ")}`);
|
|
3201
|
+
}
|
|
2851
3202
|
await this.ctx.storage.setAlarm(now + RUN_SWEEP_INTERVAL_MS);
|
|
3203
|
+
await this.ensurePlaneReaskAlarm(now);
|
|
2852
3204
|
root.end("ok", { swept: deleted });
|
|
2853
3205
|
} catch (err) {
|
|
2854
3206
|
root.fail(err);
|
|
@@ -3782,6 +4134,17 @@ export class SessionLogDO extends DurableObject<Env> {
|
|
|
3782
4134
|
);
|
|
3783
4135
|
CREATE TABLE IF NOT EXISTS notepad (k INTEGER PRIMARY KEY CHECK (k = 1), text TEXT NOT NULL, updated_at INTEGER NOT NULL);
|
|
3784
4136
|
`);
|
|
4137
|
+
// The keyed append's identity (session-log item 13): a row group's id, on
|
|
4138
|
+
// its first part; a partial unique index holds the idempotency line. Added
|
|
4139
|
+
// by ALTER so a log written before the column keeps its rows.
|
|
4140
|
+
const columns = new Set(
|
|
4141
|
+
this.sql
|
|
4142
|
+
.exec<{ name: string }>(`SELECT name FROM pragma_table_info('turns')`)
|
|
4143
|
+
.toArray()
|
|
4144
|
+
.map((r) => r.name),
|
|
4145
|
+
);
|
|
4146
|
+
if (!columns.has("row_id")) this.sql.exec(`ALTER TABLE turns ADD COLUMN row_id TEXT`);
|
|
4147
|
+
this.sql.exec(`CREATE UNIQUE INDEX IF NOT EXISTS turns_row_id ON turns(row_id) WHERE row_id IS NOT NULL`);
|
|
3785
4148
|
}
|
|
3786
4149
|
|
|
3787
4150
|
/** The index the next row lands at: one past the newest turn, 0 for an empty
|
|
@@ -3877,6 +4240,31 @@ export class SessionLogDO extends DurableObject<Env> {
|
|
|
3877
4240
|
this.sql.exec(`INSERT INTO turns_fts (rowid, text) VALUES (?, ?)`, id, text);
|
|
3878
4241
|
}
|
|
3879
4242
|
|
|
4243
|
+
/** The idempotent keyed append (session-log item 13): the parts of ONE turn
|
|
4244
|
+
* land at the tail under `rowId` — a fold of a `ship_unit` event, a
|
|
4245
|
+
* connector's turn, a migrated row. A row id the log has seen appends
|
|
4246
|
+
* nothing, so a fold read twice yields one row; two appends keep their
|
|
4247
|
+
* arrival order, since each lands at the tail inside one transaction. No
|
|
4248
|
+
* owner fence: a thread session has no one owning run. */
|
|
4249
|
+
async appendKeyed(
|
|
4250
|
+
rowId: string,
|
|
4251
|
+
rows: Array<{ part: number; json: string }>,
|
|
4252
|
+
): Promise<{ ok: true; appended: boolean }> {
|
|
4253
|
+
let appended = false;
|
|
4254
|
+
this.ctx.storage.transactionSync(() => {
|
|
4255
|
+
const seen = this.sql.exec(`SELECT 1 FROM turns WHERE row_id = ? LIMIT 1`, rowId).toArray().length > 0;
|
|
4256
|
+
if (seen) return;
|
|
4257
|
+
const idx = this.next();
|
|
4258
|
+
for (const [i, r] of rows.entries()) {
|
|
4259
|
+
this.putRow({ idx, part: r.part, json: r.json }, r.json, false);
|
|
4260
|
+
if (i === 0) this.sql.exec(`UPDATE turns SET row_id = ? WHERE idx = ? AND part = ?`, rowId, idx, r.part);
|
|
4261
|
+
}
|
|
4262
|
+
appended = true;
|
|
4263
|
+
this.enforceBytePolicy();
|
|
4264
|
+
});
|
|
4265
|
+
return { ok: true, appended };
|
|
4266
|
+
}
|
|
4267
|
+
|
|
3880
4268
|
async write(
|
|
3881
4269
|
gen: string,
|
|
3882
4270
|
rows: TranscriptRow[],
|
|
@@ -3963,7 +4351,10 @@ export class SessionLogDO extends DurableObject<Env> {
|
|
|
3963
4351
|
if (ids.length === 0) break;
|
|
3964
4352
|
for (const id of ids) {
|
|
3965
4353
|
const row = this.sql
|
|
3966
|
-
.exec<SessionTurnRow
|
|
4354
|
+
.exec<SessionTurnRow & { row_id: string | null }>(
|
|
4355
|
+
`SELECT id, idx, part, json, text, row_id FROM turns WHERE id = ?`,
|
|
4356
|
+
id,
|
|
4357
|
+
)
|
|
3967
4358
|
.toArray()[0];
|
|
3968
4359
|
if (!row) continue;
|
|
3969
4360
|
const marker = droppedToolResultRow(row.json);
|
|
@@ -3974,6 +4365,11 @@ export class SessionLogDO extends DurableObject<Env> {
|
|
|
3974
4365
|
}
|
|
3975
4366
|
const refs = attachmentRefsOf(row.json);
|
|
3976
4367
|
this.putRow({ idx: row.idx, part: row.part, json: marker }, marker, true);
|
|
4368
|
+
// The marker keeps the row's keyed-append id (item 13): `putRow` deletes
|
|
4369
|
+
// the old row, so without this the partial unique index forgets the id
|
|
4370
|
+
// and a replayed migration or fold would re-append the trimmed turn.
|
|
4371
|
+
if (row.row_id !== null)
|
|
4372
|
+
this.sql.exec(`UPDATE turns SET row_id = ? WHERE idx = ? AND part = ?`, row.row_id, row.idx, row.part);
|
|
3977
4373
|
// The marker references nothing, so an attachment only this row showed is now orphaned.
|
|
3978
4374
|
for (const ref of refs) {
|
|
3979
4375
|
if (!this.referencedElsewhere(ref, -1)) this.sql.exec(`DELETE FROM attachments WHERE ref = ?`, ref);
|
|
@@ -4135,6 +4531,7 @@ const LEDGER_ROUTES = new Set([
|
|
|
4135
4531
|
"/runs/coordinator/put",
|
|
4136
4532
|
"/runs/coordinator/replace",
|
|
4137
4533
|
"/runs/coordinator/get",
|
|
4534
|
+
"/runs/coordinator/stop",
|
|
4138
4535
|
"/runs/coordinator/units/put",
|
|
4139
4536
|
"/runs/coordinator/units/list",
|
|
4140
4537
|
"/runs/coordinator/events/append",
|
|
@@ -4165,6 +4562,7 @@ const LEDGER_ROUTES = new Set([
|
|
|
4165
4562
|
"/runs/session/tail",
|
|
4166
4563
|
"/runs/session/owner",
|
|
4167
4564
|
"/runs/session/write",
|
|
4565
|
+
"/runs/session/append",
|
|
4168
4566
|
"/runs/session/read",
|
|
4169
4567
|
"/runs/session/read-tail",
|
|
4170
4568
|
"/runs/session/clear-owner",
|
|
@@ -4176,7 +4574,19 @@ const LEDGER_ROUTES = new Set([
|
|
|
4176
4574
|
/** The plane's routes (record 0064; orchestration-plane items 7 and 8): the shadow outcome post and the
|
|
4177
4575
|
* effect acknowledgement. Both land on the ledger object of the given store
|
|
4178
4576
|
* key, like every `/runs/*` route. */
|
|
4179
|
-
const PLANE_ROUTES = new Set([
|
|
4577
|
+
const PLANE_ROUTES = new Set([
|
|
4578
|
+
"/plane/outcome",
|
|
4579
|
+
"/plane/ack",
|
|
4580
|
+
"/plane/admit",
|
|
4581
|
+
"/plane/withdraw",
|
|
4582
|
+
"/plane/deploy",
|
|
4583
|
+
"/plane/queued",
|
|
4584
|
+
"/plane/level",
|
|
4585
|
+
"/plane/observe",
|
|
4586
|
+
]);
|
|
4587
|
+
|
|
4588
|
+
const PLANE_LEVEL_NAMES = new Set(["seat", "memory", "drain"]);
|
|
4589
|
+
const PLANE_LEVEL_SIDES = new Set(["below", "above"]);
|
|
4180
4590
|
|
|
4181
4591
|
const PLANE_STAGES = new Set(["admission", "runner", "resident"]);
|
|
4182
4592
|
const PLANE_OUTCOME = /^(proceeded|refused:[a-z0-9-]+|fell_cold:[a-z0-9_-]+)$/;
|
|
@@ -4224,6 +4634,103 @@ async function handlePlane(pathname: string, body: unknown, env: Env): Promise<R
|
|
|
4224
4634
|
return json({ error: "outcome must be done, skipped or deferred" }, 400);
|
|
4225
4635
|
return json(await stub.planeAck(b.id, b.outcome as PlaneAckOutcome, now));
|
|
4226
4636
|
}
|
|
4637
|
+
if (pathname === "/plane/admit") {
|
|
4638
|
+
// The admission-stage ask (record 0064, "The queue"): the thread key, the
|
|
4639
|
+
// requester and the request in the durable inbox's shape. The route mints
|
|
4640
|
+
// the run id: `queued` stores the request under it, `admitted` names it as
|
|
4641
|
+
// the reservation.
|
|
4642
|
+
if (typeof b.threadKey !== "string" || b.threadKey.length === 0)
|
|
4643
|
+
return json({ error: "threadKey must be a non-empty string" }, 400);
|
|
4644
|
+
if (typeof b.requester !== "string" || b.requester.length === 0)
|
|
4645
|
+
return json({ error: "requester must be a non-empty string" }, 400);
|
|
4646
|
+
if (typeof b.request !== "object" || b.request === null || Array.isArray(b.request))
|
|
4647
|
+
return json({ error: "request must be a JSON object" }, 400);
|
|
4648
|
+
if (b.stage !== undefined && (typeof b.stage !== "string" || !PLANE_STAGES.has(b.stage)))
|
|
4649
|
+
return json({ error: "stage must be admission, runner or resident" }, 400);
|
|
4650
|
+
if (b.resident !== undefined && (typeof b.resident !== "string" || b.resident.length === 0))
|
|
4651
|
+
return json({ error: "resident must be a non-empty string" }, 400);
|
|
4652
|
+
if (b.restartOf !== undefined && typeof b.restartOf !== "boolean")
|
|
4653
|
+
return json({ error: "restartOf must be a boolean" }, 400);
|
|
4654
|
+
if (b.reaskMs !== undefined && (typeof b.reaskMs !== "number" || !(b.reaskMs > 0)))
|
|
4655
|
+
return json({ error: "reaskMs must be a positive number of milliseconds" }, 400);
|
|
4656
|
+
try {
|
|
4657
|
+
const answer = await stub.planeAdmit(
|
|
4658
|
+
{
|
|
4659
|
+
runId: crypto.randomUUID(),
|
|
4660
|
+
requester: b.requester,
|
|
4661
|
+
threadKey: b.threadKey,
|
|
4662
|
+
request: b.request as Record<string, unknown>,
|
|
4663
|
+
...(b.stage !== undefined ? { stage: b.stage as PlaneStage } : {}),
|
|
4664
|
+
...(b.resident !== undefined ? { resident: b.resident } : {}),
|
|
4665
|
+
...(b.restartOf !== undefined ? { restartOf: b.restartOf } : {}),
|
|
4666
|
+
...(b.reaskMs !== undefined ? { reaskMs: b.reaskMs } : {}),
|
|
4667
|
+
},
|
|
4668
|
+
now,
|
|
4669
|
+
);
|
|
4670
|
+
return json(answer);
|
|
4671
|
+
} catch (err) {
|
|
4672
|
+
// The effect caps refuse by name (record 0064): the ask is answered with
|
|
4673
|
+
// the cap's own sentence, never queued silently.
|
|
4674
|
+
return json({ error: err instanceof Error ? err.message : String(err) }, 409);
|
|
4675
|
+
}
|
|
4676
|
+
}
|
|
4677
|
+
if (pathname === "/plane/level") {
|
|
4678
|
+
// A resident's level report (record 0064): forwarded by the bot from the levels a
|
|
4679
|
+
// resident answer carried, or from the registry's drain outbox.
|
|
4680
|
+
if (typeof b.resident !== "string" || b.resident.length === 0)
|
|
4681
|
+
return json({ error: "resident must be a non-empty string" }, 400);
|
|
4682
|
+
if (typeof b.name !== "string" || !PLANE_LEVEL_NAMES.has(b.name))
|
|
4683
|
+
return json({ error: "name must be seat, memory or drain" }, 400);
|
|
4684
|
+
if (typeof b.side !== "string" || !PLANE_LEVEL_SIDES.has(b.side))
|
|
4685
|
+
return json({ error: "side must be below or above" }, 400);
|
|
4686
|
+
if (typeof b.generation !== "string") return json({ error: "generation must be a string" }, 400);
|
|
4687
|
+
return json(
|
|
4688
|
+
await stub.planeLevel(
|
|
4689
|
+
{
|
|
4690
|
+
resident: b.resident,
|
|
4691
|
+
name: b.name as "seat" | "memory" | "drain",
|
|
4692
|
+
side: b.side as "below" | "above",
|
|
4693
|
+
generation: b.generation,
|
|
4694
|
+
},
|
|
4695
|
+
now,
|
|
4696
|
+
),
|
|
4697
|
+
);
|
|
4698
|
+
}
|
|
4699
|
+
if (pathname === "/plane/observe") {
|
|
4700
|
+
// A refusal-by-name met at attach or exec (record 0064).
|
|
4701
|
+
const parsed = parseRunId(b.runId);
|
|
4702
|
+
if (!parsed.ok) return json({ error: parsed.error }, 400);
|
|
4703
|
+
if (typeof b.resident !== "string" || b.resident.length === 0)
|
|
4704
|
+
return json({ error: "resident must be a non-empty string" }, 400);
|
|
4705
|
+
if (typeof b.refusal !== "string" || b.refusal.length === 0)
|
|
4706
|
+
return json({ error: "refusal must be a non-empty string" }, 400);
|
|
4707
|
+
return json(await stub.planeObserve({ runId: parsed.value, resident: b.resident, refusal: b.refusal }, now));
|
|
4708
|
+
}
|
|
4709
|
+
if (pathname === "/plane/withdraw") {
|
|
4710
|
+
const parsed = parseRunId(b.runId);
|
|
4711
|
+
if (!parsed.ok) return json({ error: parsed.error }, 400);
|
|
4712
|
+
return json(await stub.planeWithdraw(parsed.value, now));
|
|
4713
|
+
}
|
|
4714
|
+
if (pathname === "/plane/queued") {
|
|
4715
|
+
const parsed = parseRunId(b.runId);
|
|
4716
|
+
if (!parsed.ok) return json({ error: parsed.error }, 400);
|
|
4717
|
+
return json({ row: await stub.planeQueueRowOf(parsed.value) });
|
|
4718
|
+
}
|
|
4719
|
+
if (pathname === "/plane/deploy") {
|
|
4720
|
+
// The deploy runner's post (record 0064, "The queue"): `landed` lifts the
|
|
4721
|
+
// pending-deploy window — `deploy_settled` flips and the queue walks;
|
|
4722
|
+
// `pending` opens it. Version and workers ride the log line only.
|
|
4723
|
+
if (b.phase !== "landed" && b.phase !== "pending") return json({ error: "phase must be landed or pending" }, 400);
|
|
4724
|
+
try {
|
|
4725
|
+
const r = await stub.planeWindow("deploy", b.phase === "landed" ? "lifted" : "opened", now);
|
|
4726
|
+
console.log(
|
|
4727
|
+
`[plane/deploy] ${b.phase}${typeof b.version === "string" ? ` ${b.version}` : ""} — ${r.admitted} admission(s)`,
|
|
4728
|
+
);
|
|
4729
|
+
return json({ ok: true, admitted: r.admitted });
|
|
4730
|
+
} catch (err) {
|
|
4731
|
+
return json({ error: err instanceof Error ? err.message : String(err) }, 409);
|
|
4732
|
+
}
|
|
4733
|
+
}
|
|
4227
4734
|
return json({ error: "not found" }, 404);
|
|
4228
4735
|
}
|
|
4229
4736
|
|
|
@@ -4234,6 +4741,7 @@ const WIDE_BODY_ROUTES = new Set([
|
|
|
4234
4741
|
"/runs/append",
|
|
4235
4742
|
"/runs/transcript/write",
|
|
4236
4743
|
"/runs/session/write",
|
|
4744
|
+
"/runs/session/append",
|
|
4237
4745
|
]);
|
|
4238
4746
|
/** A delivery snapshot written whole, or a refresh's patch: every merged pull request's reviews and
|
|
4239
4747
|
* its branch's workflow runs — about 7 KB a pull request (measured: 291 pull requests, 2.1 MB), so
|
|
@@ -4357,6 +4865,24 @@ function parseTranscriptRows(v: unknown): Validated<TranscriptRow[]> {
|
|
|
4357
4865
|
return { ok: true, value: v as TranscriptRow[] };
|
|
4358
4866
|
}
|
|
4359
4867
|
|
|
4868
|
+
/** The keyed append's rows (session-log item 13): the parts of one turn, no index — the object assigns the tail's. */
|
|
4869
|
+
function parseKeyedRows(v: unknown): Validated<Array<{ part: number; json: string }>> {
|
|
4870
|
+
if (!Array.isArray(v) || v.length === 0) return invalid("rows must be a non-empty array");
|
|
4871
|
+
for (const r of v) {
|
|
4872
|
+
const row = r as Record<string, unknown>;
|
|
4873
|
+
if (typeof row?.part !== "number" || !Number.isInteger(row.part) || row.part < 0 || typeof row?.json !== "string")
|
|
4874
|
+
return invalid("rows entries must be {part, json}");
|
|
4875
|
+
}
|
|
4876
|
+
return { ok: true, value: v as Array<{ part: number; json: string }> };
|
|
4877
|
+
}
|
|
4878
|
+
|
|
4879
|
+
/** The keyed append's row id (session-log item 13): non-empty, bounded — an event id, a message id with its edit stamp, a migrated row's name. */
|
|
4880
|
+
function parseRowId(v: unknown): Validated<string> {
|
|
4881
|
+
if (typeof v !== "string" || v.length === 0 || v.length > 512)
|
|
4882
|
+
return invalid("rowId must be a string of 1..512 characters");
|
|
4883
|
+
return { ok: true, value: v };
|
|
4884
|
+
}
|
|
4885
|
+
|
|
4360
4886
|
function parseSessionKey(v: unknown): Validated<string> {
|
|
4361
4887
|
if (typeof v !== "string" || !SESSION_KEY_PATTERN.test(v)) return invalid("key must be a session key");
|
|
4362
4888
|
return { ok: true, value: v };
|
|
@@ -4422,6 +4948,17 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
|
|
|
4422
4948
|
);
|
|
4423
4949
|
return fenced(r);
|
|
4424
4950
|
}
|
|
4951
|
+
if (pathname === "/runs/session/append") {
|
|
4952
|
+
const rowId = parseRowId(b.rowId);
|
|
4953
|
+
if (!rowId.ok) return json({ error: rowId.error }, 400);
|
|
4954
|
+
const rows = parseKeyedRows(b.rows);
|
|
4955
|
+
if (!rows.ok) return json({ error: rows.error }, 400);
|
|
4956
|
+
const r = await stub.appendKeyed(rowId.value, rows.value);
|
|
4957
|
+
console.log(
|
|
4958
|
+
`[runs/session/append] ${key.value} <- ${rows.value.length} row(s) under ${rowId.value}, appended=${r.appended}`,
|
|
4959
|
+
);
|
|
4960
|
+
return json(r);
|
|
4961
|
+
}
|
|
4425
4962
|
if (pathname === "/runs/session/read") {
|
|
4426
4963
|
const from = parseLogIndex(b.from, "from");
|
|
4427
4964
|
if (!from.ok) return json({ error: from.error }, 400);
|
|
@@ -4571,6 +5108,16 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
|
|
|
4571
5108
|
return json({ error: "id must be a Workflow instance id" }, 400);
|
|
4572
5109
|
return json({ instance: await stub.getInstance(b.id) });
|
|
4573
5110
|
}
|
|
5111
|
+
// The hard stop's mark on the instance row (record 0060; issue 1924): the
|
|
5112
|
+
// bot writes it when the hosted parent is sealed; the runner reads it back.
|
|
5113
|
+
if (pathname === "/runs/coordinator/stop") {
|
|
5114
|
+
if (typeof b.instanceId !== "string" || !INSTANCE_ID_PATTERN.test(b.instanceId))
|
|
5115
|
+
return json({ error: "instanceId must be a Workflow instance id" }, 400);
|
|
5116
|
+
if (typeof b.at !== "number" || !Number.isFinite(b.at)) return json({ error: "at must be a time" }, 400);
|
|
5117
|
+
const r = await stub.markInstanceStopped(b.instanceId, b.at);
|
|
5118
|
+
console.log(`[runs/coordinator/stop] ${key.value} ${b.instanceId} → ${r.ok ? "marked" : r.reason}`);
|
|
5119
|
+
return r.ok ? json(r) : json(r, 409);
|
|
5120
|
+
}
|
|
4574
5121
|
// The units of the plan an instance runs (run-history item 50): rows
|
|
4575
5122
|
// validated by the shared contract, each replaced whole; a list by instance.
|
|
4576
5123
|
if (pathname === "/runs/coordinator/units/put") {
|