@plurnk/plurnk-service 0.1.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/LICENSE +21 -0
- package/README.md +54 -0
- package/SPEC.md +1045 -0
- package/bin/plurnk-service.js +111 -0
- package/dist/core/ChannelWrite.d.ts +40 -0
- package/dist/core/ChannelWrite.d.ts.map +1 -0
- package/dist/core/ChannelWrite.js +49 -0
- package/dist/core/ChannelWrite.js.map +1 -0
- package/dist/core/Db.d.ts +14 -0
- package/dist/core/Db.d.ts.map +1 -0
- package/dist/core/Db.js +7 -0
- package/dist/core/Db.js.map +1 -0
- package/dist/core/Engine.d.ts +107 -0
- package/dist/core/Engine.d.ts.map +1 -0
- package/dist/core/Engine.js +937 -0
- package/dist/core/Engine.js.map +1 -0
- package/dist/core/EnvFlags.d.ts +10 -0
- package/dist/core/EnvFlags.d.ts.map +1 -0
- package/dist/core/EnvFlags.js +82 -0
- package/dist/core/EnvFlags.js.map +1 -0
- package/dist/core/Migrator.d.ts +19 -0
- package/dist/core/Migrator.d.ts.map +1 -0
- package/dist/core/Migrator.js +55 -0
- package/dist/core/Migrator.js.map +1 -0
- package/dist/core/PluginLoader.d.ts +14 -0
- package/dist/core/PluginLoader.d.ts.map +1 -0
- package/dist/core/PluginLoader.js +100 -0
- package/dist/core/PluginLoader.js.map +1 -0
- package/dist/core/ProviderRegistry.d.ts +42 -0
- package/dist/core/ProviderRegistry.d.ts.map +1 -0
- package/dist/core/ProviderRegistry.js +72 -0
- package/dist/core/ProviderRegistry.js.map +1 -0
- package/dist/core/SchemeRegistry.d.ts +13 -0
- package/dist/core/SchemeRegistry.d.ts.map +1 -0
- package/dist/core/SchemeRegistry.js +50 -0
- package/dist/core/SchemeRegistry.js.map +1 -0
- package/dist/core/scheme-types.d.ts +37 -0
- package/dist/core/scheme-types.d.ts.map +1 -0
- package/dist/core/scheme-types.js +9 -0
- package/dist/core/scheme-types.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +51 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/Mock.d.ts +43 -0
- package/dist/providers/Mock.d.ts.map +1 -0
- package/dist/providers/Mock.js +36 -0
- package/dist/providers/Mock.js.map +1 -0
- package/dist/schemes/Exec.d.ts +5 -0
- package/dist/schemes/Exec.d.ts.map +1 -0
- package/dist/schemes/Exec.js +20 -0
- package/dist/schemes/Exec.js.map +1 -0
- package/dist/schemes/File.d.ts +35 -0
- package/dist/schemes/File.d.ts.map +1 -0
- package/dist/schemes/File.js +149 -0
- package/dist/schemes/File.js.map +1 -0
- package/dist/schemes/Known.d.ts +19 -0
- package/dist/schemes/Known.d.ts.map +1 -0
- package/dist/schemes/Known.js +44 -0
- package/dist/schemes/Known.js.map +1 -0
- package/dist/schemes/Log.d.ts +13 -0
- package/dist/schemes/Log.d.ts.map +1 -0
- package/dist/schemes/Log.js +38 -0
- package/dist/schemes/Log.js.map +1 -0
- package/dist/schemes/Plurnk.d.ts +5 -0
- package/dist/schemes/Plurnk.d.ts.map +1 -0
- package/dist/schemes/Plurnk.js +15 -0
- package/dist/schemes/Plurnk.js.map +1 -0
- package/dist/schemes/Skill.d.ts +19 -0
- package/dist/schemes/Skill.d.ts.map +1 -0
- package/dist/schemes/Skill.js +46 -0
- package/dist/schemes/Skill.js.map +1 -0
- package/dist/schemes/Unknown.d.ts +19 -0
- package/dist/schemes/Unknown.d.ts.map +1 -0
- package/dist/schemes/Unknown.js +44 -0
- package/dist/schemes/Unknown.js.map +1 -0
- package/dist/schemes/_entry-crud.d.ts +24 -0
- package/dist/schemes/_entry-crud.d.ts.map +1 -0
- package/dist/schemes/_entry-crud.js +56 -0
- package/dist/schemes/_entry-crud.js.map +1 -0
- package/dist/schemes/_entry-find.d.ts +10 -0
- package/dist/schemes/_entry-find.d.ts.map +1 -0
- package/dist/schemes/_entry-find.js +41 -0
- package/dist/schemes/_entry-find.js.map +1 -0
- package/dist/schemes/_entry-ops.d.ts +21 -0
- package/dist/schemes/_entry-ops.d.ts.map +1 -0
- package/dist/schemes/_entry-ops.js +146 -0
- package/dist/schemes/_entry-ops.js.map +1 -0
- package/dist/schemes/_entry-send.d.ts +8 -0
- package/dist/schemes/_entry-send.d.ts.map +1 -0
- package/dist/schemes/_entry-send.js +56 -0
- package/dist/schemes/_entry-send.js.map +1 -0
- package/dist/server/ClientConnection.d.ts +22 -0
- package/dist/server/ClientConnection.d.ts.map +1 -0
- package/dist/server/ClientConnection.js +120 -0
- package/dist/server/ClientConnection.js.map +1 -0
- package/dist/server/Daemon.d.ts +43 -0
- package/dist/server/Daemon.d.ts.map +1 -0
- package/dist/server/Daemon.js +252 -0
- package/dist/server/Daemon.js.map +1 -0
- package/dist/server/MethodRegistry.d.ts +56 -0
- package/dist/server/MethodRegistry.d.ts.map +1 -0
- package/dist/server/MethodRegistry.js +55 -0
- package/dist/server/MethodRegistry.js.map +1 -0
- package/dist/server/clientTurn.d.ts +3 -0
- package/dist/server/clientTurn.d.ts.map +1 -0
- package/dist/server/clientTurn.js +22 -0
- package/dist/server/clientTurn.js.map +1 -0
- package/dist/server/dsl.d.ts +47 -0
- package/dist/server/dsl.d.ts.map +1 -0
- package/dist/server/dsl.js +117 -0
- package/dist/server/dsl.js.map +1 -0
- package/dist/server/envelope.d.ts +44 -0
- package/dist/server/envelope.d.ts.map +1 -0
- package/dist/server/envelope.js +113 -0
- package/dist/server/envelope.js.map +1 -0
- package/dist/server/logEntry.d.ts +30 -0
- package/dist/server/logEntry.d.ts.map +1 -0
- package/dist/server/logEntry.js +43 -0
- package/dist/server/logEntry.js.map +1 -0
- package/dist/server/methods/_dispatchAsClient.d.ts +8 -0
- package/dist/server/methods/_dispatchAsClient.d.ts.map +1 -0
- package/dist/server/methods/_dispatchAsClient.js +31 -0
- package/dist/server/methods/_dispatchAsClient.js.map +1 -0
- package/dist/server/methods/discover.d.ts +3 -0
- package/dist/server/methods/discover.d.ts.map +1 -0
- package/dist/server/methods/discover.js +7 -0
- package/dist/server/methods/discover.js.map +1 -0
- package/dist/server/methods/entry_read.d.ts +3 -0
- package/dist/server/methods/entry_read.d.ts.map +1 -0
- package/dist/server/methods/entry_read.js +56 -0
- package/dist/server/methods/entry_read.js.map +1 -0
- package/dist/server/methods/log_read.d.ts +3 -0
- package/dist/server/methods/log_read.d.ts.map +1 -0
- package/dist/server/methods/log_read.js +39 -0
- package/dist/server/methods/log_read.js.map +1 -0
- package/dist/server/methods/loop_resolve.d.ts +3 -0
- package/dist/server/methods/loop_resolve.d.ts.map +1 -0
- package/dist/server/methods/loop_resolve.js +45 -0
- package/dist/server/methods/loop_resolve.js.map +1 -0
- package/dist/server/methods/loop_run.d.ts +3 -0
- package/dist/server/methods/loop_run.d.ts.map +1 -0
- package/dist/server/methods/loop_run.js +112 -0
- package/dist/server/methods/loop_run.js.map +1 -0
- package/dist/server/methods/op_copy.d.ts +3 -0
- package/dist/server/methods/op_copy.d.ts.map +1 -0
- package/dist/server/methods/op_copy.js +24 -0
- package/dist/server/methods/op_copy.js.map +1 -0
- package/dist/server/methods/op_dispatch.d.ts +3 -0
- package/dist/server/methods/op_dispatch.d.ts.map +1 -0
- package/dist/server/methods/op_dispatch.js +17 -0
- package/dist/server/methods/op_dispatch.js.map +1 -0
- package/dist/server/methods/op_edit.d.ts +3 -0
- package/dist/server/methods/op_edit.d.ts.map +1 -0
- package/dist/server/methods/op_edit.js +22 -0
- package/dist/server/methods/op_edit.js.map +1 -0
- package/dist/server/methods/op_exec.d.ts +3 -0
- package/dist/server/methods/op_exec.d.ts.map +1 -0
- package/dist/server/methods/op_exec.js +19 -0
- package/dist/server/methods/op_exec.js.map +1 -0
- package/dist/server/methods/op_find.d.ts +3 -0
- package/dist/server/methods/op_find.d.ts.map +1 -0
- package/dist/server/methods/op_find.js +22 -0
- package/dist/server/methods/op_find.js.map +1 -0
- package/dist/server/methods/op_hide.d.ts +3 -0
- package/dist/server/methods/op_hide.d.ts.map +1 -0
- package/dist/server/methods/op_hide.js +22 -0
- package/dist/server/methods/op_hide.js.map +1 -0
- package/dist/server/methods/op_move.d.ts +3 -0
- package/dist/server/methods/op_move.d.ts.map +1 -0
- package/dist/server/methods/op_move.js +22 -0
- package/dist/server/methods/op_move.js.map +1 -0
- package/dist/server/methods/op_parse.d.ts +3 -0
- package/dist/server/methods/op_parse.d.ts.map +1 -0
- package/dist/server/methods/op_parse.js +23 -0
- package/dist/server/methods/op_parse.js.map +1 -0
- package/dist/server/methods/op_read.d.ts +3 -0
- package/dist/server/methods/op_read.d.ts.map +1 -0
- package/dist/server/methods/op_read.js +22 -0
- package/dist/server/methods/op_read.js.map +1 -0
- package/dist/server/methods/op_send.d.ts +3 -0
- package/dist/server/methods/op_send.d.ts.map +1 -0
- package/dist/server/methods/op_send.js +21 -0
- package/dist/server/methods/op_send.js.map +1 -0
- package/dist/server/methods/op_show.d.ts +3 -0
- package/dist/server/methods/op_show.d.ts.map +1 -0
- package/dist/server/methods/op_show.js +22 -0
- package/dist/server/methods/op_show.js.map +1 -0
- package/dist/server/methods/ping.d.ts +3 -0
- package/dist/server/methods/ping.d.ts.map +1 -0
- package/dist/server/methods/ping.js +7 -0
- package/dist/server/methods/ping.js.map +1 -0
- package/dist/server/methods/providers_list.d.ts +3 -0
- package/dist/server/methods/providers_list.d.ts.map +1 -0
- package/dist/server/methods/providers_list.js +19 -0
- package/dist/server/methods/providers_list.js.map +1 -0
- package/dist/server/methods/session_attach.d.ts +3 -0
- package/dist/server/methods/session_attach.d.ts.map +1 -0
- package/dist/server/methods/session_attach.js +42 -0
- package/dist/server/methods/session_attach.js.map +1 -0
- package/dist/server/methods/session_create.d.ts +3 -0
- package/dist/server/methods/session_create.d.ts.map +1 -0
- package/dist/server/methods/session_create.js +53 -0
- package/dist/server/methods/session_create.js.map +1 -0
- package/dist/server/methods/session_list.d.ts +3 -0
- package/dist/server/methods/session_list.d.ts.map +1 -0
- package/dist/server/methods/session_list.js +8 -0
- package/dist/server/methods/session_list.js.map +1 -0
- package/dist/server/methods/session_runs.d.ts +3 -0
- package/dist/server/methods/session_runs.d.ts.map +1 -0
- package/dist/server/methods/session_runs.js +18 -0
- package/dist/server/methods/session_runs.js.map +1 -0
- package/dist/server/methods/session_set_persona.d.ts +3 -0
- package/dist/server/methods/session_set_persona.d.ts.map +1 -0
- package/dist/server/methods/session_set_persona.js +27 -0
- package/dist/server/methods/session_set_persona.js.map +1 -0
- package/dist/server/methods/session_set_root.d.ts +3 -0
- package/dist/server/methods/session_set_root.d.ts.map +1 -0
- package/dist/server/methods/session_set_root.js +33 -0
- package/dist/server/methods/session_set_root.js.map +1 -0
- package/dist/server/yolo.d.ts +4 -0
- package/dist/server/yolo.d.ts.map +1 -0
- package/dist/server/yolo.js +48 -0
- package/dist/server/yolo.js.map +1 -0
- package/migrations/001_sessions.sql +11 -0
- package/migrations/002_runs.sql +16 -0
- package/migrations/003_loops.sql +12 -0
- package/migrations/004_turns.sql +18 -0
- package/migrations/005_entries.sql +44 -0
- package/migrations/006_log_entries.sql +51 -0
- package/migrations/007_visibility.sql +10 -0
- package/migrations/008_schemes_providers.sql +25 -0
- package/migrations/009_cost_rollups.sql +44 -0
- package/migrations/010_subscriptions.sql +36 -0
- package/migrations/011_turn_call_metadata.sql +7 -0
- package/migrations/012_proposal_lifecycle.sql +22 -0
- package/migrations/013_log_entries_lifecycle_columns.sql +22 -0
- package/migrations/014_loop_flags.sql +9 -0
- package/migrations/015_sessions_project_root.sql +6 -0
- package/migrations/016_persona_columns.sql +11 -0
- package/package.json +66 -0
- package/persona.md +1 -0
|
@@ -0,0 +1,937 @@
|
|
|
1
|
+
import { PlurnkParser } from "@plurnk/plurnk-grammar";
|
|
2
|
+
import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
|
|
3
|
+
import { DEFAULT_LOOP_FLAGS } from "./scheme-types.js";
|
|
4
|
+
// @ts-expect-error -- plain JS module shared with bin/digest.js so wire
|
|
5
|
+
// projection and digest projection are structurally one function.
|
|
6
|
+
import { packetToWireMessages } from "./packet-wire.js";
|
|
7
|
+
// SCHEMES.md §8: writer must be in target scheme's manifest.writableBy.
|
|
8
|
+
// SHOW/HIDE/READ/FIND are not gated — they touch visibility metadata or read.
|
|
9
|
+
const MUTATING_OPS = new Set(["EDIT", "SEND", "COPY", "MOVE", "EXEC"]);
|
|
10
|
+
const DEFAULT_PREVIEW_BUDGET = 256;
|
|
11
|
+
const DEFAULT_MAX_STRIKES = 3;
|
|
12
|
+
const readBudget = () => {
|
|
13
|
+
const raw = process.env.PLURNK_ENTRY_SIZE_DEFAULT_TOKENS;
|
|
14
|
+
if (raw === undefined || raw.length === 0)
|
|
15
|
+
return DEFAULT_PREVIEW_BUDGET;
|
|
16
|
+
const n = Number.parseInt(raw, 10);
|
|
17
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
18
|
+
return DEFAULT_PREVIEW_BUDGET;
|
|
19
|
+
return n;
|
|
20
|
+
};
|
|
21
|
+
const readMaxStrikes = () => {
|
|
22
|
+
const raw = process.env.PLURNK_MAX_STRIKES;
|
|
23
|
+
if (raw === undefined || raw.length === 0)
|
|
24
|
+
return DEFAULT_MAX_STRIKES;
|
|
25
|
+
const n = Number.parseInt(raw, 10);
|
|
26
|
+
if (!Number.isFinite(n) || n < 0)
|
|
27
|
+
return DEFAULT_MAX_STRIKES;
|
|
28
|
+
return n;
|
|
29
|
+
};
|
|
30
|
+
// Resolution timeout — proposed entries auto-cancel if nothing arrives
|
|
31
|
+
// within this window. Per AGENTS.md §Phase E.2.
|
|
32
|
+
const PROPOSAL_TIMEOUT_DEFAULT_MS = 300000;
|
|
33
|
+
const readProposalTimeoutMs = () => {
|
|
34
|
+
const raw = process.env.PLURNK_PROPOSAL_TIMEOUT_MS;
|
|
35
|
+
if (raw === undefined || raw.length === 0)
|
|
36
|
+
return PROPOSAL_TIMEOUT_DEFAULT_MS;
|
|
37
|
+
const n = Number(raw);
|
|
38
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
39
|
+
return PROPOSAL_TIMEOUT_DEFAULT_MS;
|
|
40
|
+
return n;
|
|
41
|
+
};
|
|
42
|
+
const pathnameFromPath = (path) => {
|
|
43
|
+
if (path.kind === "url")
|
|
44
|
+
return path.pathname;
|
|
45
|
+
return path.raw;
|
|
46
|
+
};
|
|
47
|
+
// Default turn.status when ops were emitted but no SEND. Model is implicitly
|
|
48
|
+
// continuing; loop.status stays 102 either way (only SEND broadcast advances
|
|
49
|
+
// loop terminal). No strike, no telemetry.
|
|
50
|
+
const TURN_STATUS_IMPLICIT_CONTINUE = 102;
|
|
51
|
+
// Status assigned to a turn that emitted NO ops at all. Strike-worthy; the
|
|
52
|
+
// action routes through telemetry.errors[] (§15.1).
|
|
53
|
+
const TURN_STATUS_NO_OPS = 422;
|
|
54
|
+
// Rail #38: action-entry statuses that DON'T accumulate strikes. Model adapted
|
|
55
|
+
// to a finding (not_found, op_not_supported); no penalty. Rummy parallel:
|
|
56
|
+
// SOFT_FAILURE_OUTCOMES = {"not_found", "unparsed"}.
|
|
57
|
+
const SOFT_FAILURE_STATUSES = new Set([404, 501]);
|
|
58
|
+
const DEFAULT_MIN_CYCLES = 3;
|
|
59
|
+
const DEFAULT_MAX_CYCLE_PERIOD = 4;
|
|
60
|
+
const readPositiveInt = (envVar, fallback) => {
|
|
61
|
+
const raw = process.env[envVar];
|
|
62
|
+
if (raw === undefined || raw.length === 0)
|
|
63
|
+
return fallback;
|
|
64
|
+
const n = Number.parseInt(raw, 10);
|
|
65
|
+
if (!Number.isFinite(n) || n < 1)
|
|
66
|
+
return fallback;
|
|
67
|
+
return n;
|
|
68
|
+
};
|
|
69
|
+
// Per-op fingerprint: op verb + target URI. Body deliberately excluded so the
|
|
70
|
+
// model writing varied content to the same target still trips. Path kind is
|
|
71
|
+
// included as a discriminator (url vs local). Rummy parallel: scheme +
|
|
72
|
+
// sorted attributes joined by '='.
|
|
73
|
+
const fingerprintOp = (stmt) => {
|
|
74
|
+
const path = stmt.path;
|
|
75
|
+
if (path === null)
|
|
76
|
+
return `${stmt.op}|(no-path)`;
|
|
77
|
+
if (path.kind === "url")
|
|
78
|
+
return `${stmt.op}|${path.scheme}://${path.pathname}`;
|
|
79
|
+
return `${stmt.op}|local:${path.raw}`;
|
|
80
|
+
};
|
|
81
|
+
// Per-turn fingerprint: sorted set of per-op fingerprints, joined. Order
|
|
82
|
+
// within a turn doesn't matter — we want the SET of activities.
|
|
83
|
+
export const fingerprintTurn = (ops) => {
|
|
84
|
+
return ops.map(fingerprintOp).toSorted().join(",");
|
|
85
|
+
};
|
|
86
|
+
// Rail #39 cycle detector. For each candidate period k in [1, maxCyclePeriod],
|
|
87
|
+
// check whether the last k*minCycles entries form minCycles repetitions of the
|
|
88
|
+
// same length-k pattern. O(maxCyclePeriod × minCycles × max k) ≈ tiny. Rummy
|
|
89
|
+
// parallel: src/plugins/error/error.js detectCycle.
|
|
90
|
+
export const detectCycle = (history, minCycles, maxCyclePeriod) => {
|
|
91
|
+
for (let k = 1; k <= maxCyclePeriod; k++) {
|
|
92
|
+
const needed = k * minCycles;
|
|
93
|
+
if (history.length < needed)
|
|
94
|
+
continue;
|
|
95
|
+
const tail = history.slice(-needed);
|
|
96
|
+
const cycle = tail.slice(0, k);
|
|
97
|
+
let match = true;
|
|
98
|
+
outer: for (let rep = 0; rep < minCycles; rep++) {
|
|
99
|
+
for (let j = 0; j < k; j++) {
|
|
100
|
+
if (tail[rep * k + j] !== cycle[j]) {
|
|
101
|
+
match = false;
|
|
102
|
+
break outer;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (match)
|
|
107
|
+
return { detected: true, period: k, cycles: minCycles };
|
|
108
|
+
}
|
|
109
|
+
return { detected: false };
|
|
110
|
+
};
|
|
111
|
+
export default class Engine {
|
|
112
|
+
#db;
|
|
113
|
+
#schemes;
|
|
114
|
+
#mimetypes;
|
|
115
|
+
#previewBudget;
|
|
116
|
+
// Per-loop transient buffer of actionless failures pending surface in the
|
|
117
|
+
// NEXT packet's user.telemetry.errors[]. Drained by #buildTelemetryErrors.
|
|
118
|
+
// Map<loopId, TelemetryError[]>. SPEC §15.1.
|
|
119
|
+
#telemetryBuffer = new Map();
|
|
120
|
+
// Rail #38 strike state per loop. `streak` = consecutive struck turns;
|
|
121
|
+
// resets on a clean turn. `turnErrors` is bumped externally by per-turn
|
|
122
|
+
// rails (cycle detection #39, etc.) — read and reset at end of each turn.
|
|
123
|
+
// `history` holds per-turn fingerprints for rail #39 cycle detection.
|
|
124
|
+
#strikeState = new Map();
|
|
125
|
+
// Proposal lifecycle (task #42): pending dispatch pauses waiting for
|
|
126
|
+
// resolution. Engine.runTurn awaits the promise when a scheme returns
|
|
127
|
+
// status 202; Engine.resolveProposal feeds the resolution back in. Map
|
|
128
|
+
// is per-log-entry-id; entries clear on resolution. See AGENTS.md
|
|
129
|
+
// §Phase E for the broader lifecycle plan.
|
|
130
|
+
#pendingProposals = new Map();
|
|
131
|
+
// External observers of proposal lifecycle events. Daemon subscribes
|
|
132
|
+
// here to push `loop/proposal` notifications when an entry enters
|
|
133
|
+
// pending state. YOLO listener (Phase E.3) subscribes here too. Lean
|
|
134
|
+
// event emitter — no priority, no veto chain at this layer; filter
|
|
135
|
+
// chains come later if a real consumer needs them.
|
|
136
|
+
#proposalPendingListeners = [];
|
|
137
|
+
constructor({ db, schemes, mimetypes }) {
|
|
138
|
+
this.#db = db;
|
|
139
|
+
this.#schemes = schemes;
|
|
140
|
+
// Default to empty discovery — standalone Engine construction (in
|
|
141
|
+
// tests) gets no handlers, and content flows through the framework's
|
|
142
|
+
// raw-content fitContent fallback. Daemon-managed Engine receives a
|
|
143
|
+
// production-configured Mimetypes via the constructor arg.
|
|
144
|
+
this.#mimetypes = mimetypes ?? new Mimetypes({
|
|
145
|
+
discovery: { registry: emptyRegistry(), handlers: new Map() },
|
|
146
|
+
});
|
|
147
|
+
this.#previewBudget = readBudget();
|
|
148
|
+
}
|
|
149
|
+
#pushTelemetry(loopId, error) {
|
|
150
|
+
const existing = this.#telemetryBuffer.get(loopId);
|
|
151
|
+
if (existing === undefined)
|
|
152
|
+
this.#telemetryBuffer.set(loopId, [error]);
|
|
153
|
+
else
|
|
154
|
+
existing.push(error);
|
|
155
|
+
}
|
|
156
|
+
#drainTelemetry(loopId) {
|
|
157
|
+
const buf = this.#telemetryBuffer.get(loopId);
|
|
158
|
+
if (buf === undefined)
|
|
159
|
+
return [];
|
|
160
|
+
this.#telemetryBuffer.delete(loopId);
|
|
161
|
+
return buf;
|
|
162
|
+
}
|
|
163
|
+
async runLoop({ provider, messages, persona = "", sessionId, runId, loopId, maxTurns = 50, maxStrikes = readMaxStrikes(), minCycles = readPositiveInt("PLURNK_MIN_CYCLES", DEFAULT_MIN_CYCLES), maxCyclePeriod = readPositiveInt("PLURNK_MAX_CYCLE_PERIOD", DEFAULT_MAX_CYCLE_PERIOD), origin = "model", signal, onDispatch, }) {
|
|
164
|
+
const turnIds = [];
|
|
165
|
+
const suddenDeathThreshold = maxTurns - maxStrikes;
|
|
166
|
+
const cleanup = () => {
|
|
167
|
+
this.#strikeState.delete(loopId);
|
|
168
|
+
this.#telemetryBuffer.delete(loopId);
|
|
169
|
+
};
|
|
170
|
+
while (true) {
|
|
171
|
+
signal?.throwIfAborted();
|
|
172
|
+
const row = await this.#db.engine_loop_status.get({ loop_id: loopId });
|
|
173
|
+
if (row === undefined)
|
|
174
|
+
throw new Error(`Engine.runLoop: loop ${loopId} not found`);
|
|
175
|
+
if (row.status !== 102) {
|
|
176
|
+
cleanup();
|
|
177
|
+
return { turnIds, finalStatus: row.status, hitMaxTurns: false, reason: "external" };
|
|
178
|
+
}
|
|
179
|
+
if (turnIds.length >= maxTurns) {
|
|
180
|
+
await this.#db.engine_loop_cancel.run({ loop_id: loopId });
|
|
181
|
+
cleanup();
|
|
182
|
+
return { turnIds, finalStatus: 499, hitMaxTurns: true, reason: "max_turns" };
|
|
183
|
+
}
|
|
184
|
+
const turn = await this.runTurn({
|
|
185
|
+
provider, messages, persona, sessionId, runId, loopId, origin, signal, onDispatch,
|
|
186
|
+
turnNumber: turnIds.length + 1, maxTurns,
|
|
187
|
+
});
|
|
188
|
+
turnIds.push(turn.turnId);
|
|
189
|
+
// Rail #39: cycle detection. Push this turn's fingerprint to
|
|
190
|
+
// history, scan for repetition patterns. Detection bumps
|
|
191
|
+
// turnErrors so the strike system handles abandonment naturally.
|
|
192
|
+
const state = this.#strikeState.get(loopId) ?? { streak: 0, turnErrors: 0, history: [] };
|
|
193
|
+
state.history.push(turn.fingerprint);
|
|
194
|
+
const cycle = detectCycle(state.history, minCycles, maxCyclePeriod);
|
|
195
|
+
if (cycle.detected) {
|
|
196
|
+
state.turnErrors++;
|
|
197
|
+
this.#pushTelemetry(loopId, {
|
|
198
|
+
kind: "cycle",
|
|
199
|
+
period: cycle.period,
|
|
200
|
+
cycles: cycle.cycles,
|
|
201
|
+
message: `repeating pattern detected: ${cycle.cycles}× period-${cycle.period}; vary your approach`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
this.#strikeState.set(loopId, state);
|
|
205
|
+
// Rail #38: strike accounting. Three sources strike a turn:
|
|
206
|
+
// 1. recordedFailed — any action-entry at hard failure status
|
|
207
|
+
// (>= 400 and not in SOFT_FAILURE_STATUSES).
|
|
208
|
+
// 2. noOps — turn.status === TURN_STATUS_NO_OPS (per #41).
|
|
209
|
+
// 3. turnErrors — externally bumped by per-turn rails (#39 cycle).
|
|
210
|
+
// Struck → streak++; clean → streak = 0. Threshold → abandon.
|
|
211
|
+
const recordedFailed = turn.statuses.some((s) => s >= 400 && !SOFT_FAILURE_STATUSES.has(s));
|
|
212
|
+
const noOps = turn.status === TURN_STATUS_NO_OPS;
|
|
213
|
+
const struck = noOps || recordedFailed || state.turnErrors > 0;
|
|
214
|
+
if (struck) {
|
|
215
|
+
state.streak++;
|
|
216
|
+
this.#pushTelemetry(loopId, {
|
|
217
|
+
kind: "strike",
|
|
218
|
+
streak: state.streak,
|
|
219
|
+
maxStrikes,
|
|
220
|
+
reason: noOps ? "no_ops" : recordedFailed ? "recorded_failure" : "rail",
|
|
221
|
+
});
|
|
222
|
+
if (state.streak >= maxStrikes) {
|
|
223
|
+
await this.#db.engine_loop_cancel.run({ loop_id: loopId });
|
|
224
|
+
cleanup();
|
|
225
|
+
return { turnIds, finalStatus: 499, hitMaxTurns: false, reason: "strike_threshold" };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
state.streak = 0;
|
|
230
|
+
}
|
|
231
|
+
state.turnErrors = 0;
|
|
232
|
+
this.#strikeState.set(loopId, state);
|
|
233
|
+
// Rail #40: sudden-death soft warning. When the loop enters the
|
|
234
|
+
// last maxStrikes-sized window before maxTurns, push a warning
|
|
235
|
+
// each turn so the model can wrap up before the hard cancel.
|
|
236
|
+
// Soft: no strike, no loop-status change. SPEC §15.1.
|
|
237
|
+
if (turnIds.length >= suddenDeathThreshold && turnIds.length < maxTurns) {
|
|
238
|
+
this.#pushTelemetry(loopId, {
|
|
239
|
+
kind: "sudden_death",
|
|
240
|
+
message: `approaching max turns: ${turnIds.length} of ${maxTurns}; emit SEND[200] to complete`,
|
|
241
|
+
remaining: maxTurns - turnIds.length,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async runTurn({ provider, messages, persona = "", sessionId, runId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
|
|
247
|
+
// Build the spec'd packet (Packet.json) request half BEFORE the
|
|
248
|
+
// provider call. The wire payload is a projection OF this packet;
|
|
249
|
+
// the stored packet is the same object completed with the
|
|
250
|
+
// assistant section after the response arrives.
|
|
251
|
+
const requestPacket = await this.#buildRequestPacket({
|
|
252
|
+
initialMessages: messages, persona, runId, loopId, turnNumber, maxTurns, provider,
|
|
253
|
+
});
|
|
254
|
+
const modelMessages = this.#packetToWireMessages(requestPacket);
|
|
255
|
+
const response = await provider.generate({ messages: modelMessages, signal });
|
|
256
|
+
// Engine splits wire-level response: emission (content, reasoning,
|
|
257
|
+
// parsed ops) → packet.assistant per Packet.json §assistant;
|
|
258
|
+
// call-metadata (usage, finishReason, model) → Turn columns per
|
|
259
|
+
// Turn.json. Mixing the two on packet.assistant was the wrong layer.
|
|
260
|
+
const { packetAssistant, callMetadata } = this.#splitResponse(response);
|
|
261
|
+
const opsCount = packetAssistant.ops.length;
|
|
262
|
+
const sendOp = packetAssistant.ops.findLast((op) => op.op === "SEND" && typeof op.signal === "number");
|
|
263
|
+
// Rail #41 (revised): the per-turn requirement is "emit at least one
|
|
264
|
+
// op," not "emit a terminal SEND." SEND is purely a signal verb; many
|
|
265
|
+
// turns may pass without one. An empty op list is the only strike.
|
|
266
|
+
const turnStatus = sendOp !== undefined
|
|
267
|
+
? sendOp.signal
|
|
268
|
+
: opsCount === 0 ? TURN_STATUS_NO_OPS : TURN_STATUS_IMPLICIT_CONTINUE;
|
|
269
|
+
const seqRow = await this.#db.engine_next_turn_sequence.get({ loop_id: loopId });
|
|
270
|
+
const seq = seqRow.next;
|
|
271
|
+
// Complete the spec'd packet by adding the response section.
|
|
272
|
+
// requestPacket already has system + user matching what was sent
|
|
273
|
+
// to the LLM (one source of truth across wire payload and storage).
|
|
274
|
+
const packet = this.#completePacket(requestPacket, packetAssistant, response.assistantRaw, provider);
|
|
275
|
+
const { usage, finishReason, model } = callMetadata;
|
|
276
|
+
const turnRow = await this.#db.engine_insert_turn.get({
|
|
277
|
+
loop_id: loopId,
|
|
278
|
+
sequence: seq,
|
|
279
|
+
status: turnStatus,
|
|
280
|
+
packet: JSON.stringify(packet),
|
|
281
|
+
usage_prompt: usage.prompt,
|
|
282
|
+
usage_completion: usage.completion,
|
|
283
|
+
usage_cached: usage.cached,
|
|
284
|
+
usage_cost_pico: provider.costFor(usage),
|
|
285
|
+
finish_reason: finishReason,
|
|
286
|
+
model,
|
|
287
|
+
});
|
|
288
|
+
if (turnRow === undefined)
|
|
289
|
+
throw new Error("Engine.runTurn: turn insert returned no row");
|
|
290
|
+
const turnId = turnRow.id;
|
|
291
|
+
const statuses = [];
|
|
292
|
+
for (const [actionIndex, statement] of packetAssistant.ops.entries()) {
|
|
293
|
+
const result = await this.dispatch({
|
|
294
|
+
statement, sessionId, runId, loopId, turnId, actionIndex, origin, onDispatch,
|
|
295
|
+
});
|
|
296
|
+
statuses.push(result.status);
|
|
297
|
+
}
|
|
298
|
+
if (opsCount === 0) {
|
|
299
|
+
// Rail #41 (revised): per-turn requirement is "emit at least one
|
|
300
|
+
// op." Zero ops = actionless failure. SEND specifically is not
|
|
301
|
+
// required — any of the 9 grammar ops satisfies. Pushed AFTER
|
|
302
|
+
// #buildPacket so this turn's drain doesn't consume it.
|
|
303
|
+
this.#pushTelemetry(loopId, {
|
|
304
|
+
kind: "no_ops",
|
|
305
|
+
message: "turn ended without emitting any op; emit at least one operation per turn",
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return { turnId, status: turnStatus, statuses, fingerprint: fingerprintTurn(packetAssistant.ops) };
|
|
309
|
+
}
|
|
310
|
+
// Split the wire-level ProviderResponse into the two destinations:
|
|
311
|
+
// packet.assistant gets the model's emission (content, ops, reasoning);
|
|
312
|
+
// Turn columns get the call-metadata (usage, finishReason, model).
|
|
313
|
+
// PROVIDERS.md §3.3 text-fragment scraping policy lives here — engine
|
|
314
|
+
// owns the parse and the scraping rule, providers stay grammar-unaware.
|
|
315
|
+
//
|
|
316
|
+
// Test-fixture escape hatch: the Mock provider may pre-supply `ops` on
|
|
317
|
+
// its assistant payload to skip the parse roundtrip. The wire Provider
|
|
318
|
+
// contract has no `ops` field; only Mock exposes one. Real providers
|
|
319
|
+
// always take the parse path because their `assistant.ops` is undefined.
|
|
320
|
+
#splitResponse(response) {
|
|
321
|
+
const { assistant } = response;
|
|
322
|
+
const preParsedOps = assistant.ops;
|
|
323
|
+
const ops = [];
|
|
324
|
+
const textFragments = [];
|
|
325
|
+
if (preParsedOps !== undefined) {
|
|
326
|
+
ops.push(...preParsedOps);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
const parsed = PlurnkParser.parse(assistant.content);
|
|
330
|
+
for (const item of parsed.items) {
|
|
331
|
+
if (item.kind === "statement")
|
|
332
|
+
ops.push(item.statement);
|
|
333
|
+
else if (item.kind === "text") {
|
|
334
|
+
const trimmed = item.text.trim();
|
|
335
|
+
if (trimmed.length > 0)
|
|
336
|
+
textFragments.push(trimmed);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const wireReasoning = assistant.reasoning ?? "";
|
|
341
|
+
const scrapedReasoning = textFragments.join("\n");
|
|
342
|
+
const reasoningParts = [wireReasoning, scrapedReasoning].filter((s) => s.length > 0);
|
|
343
|
+
const reasoning = reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null;
|
|
344
|
+
return {
|
|
345
|
+
packetAssistant: { content: assistant.content, ops, reasoning },
|
|
346
|
+
callMetadata: { usage: assistant.usage, finishReason: assistant.finishReason, model: assistant.model },
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
// Assemble the request half of the spec'd packet (Packet.json §system
|
|
350
|
+
// and §user) BEFORE the provider call. The same packet object is then
|
|
351
|
+
// completed with assistant + assistantRaw after the model responds, so
|
|
352
|
+
// the stored packet and the wire payload share one source of truth.
|
|
353
|
+
// Per Packet.json: user.prompt is "Copy of loop.prompt — never null on
|
|
354
|
+
// a continuation turn"; the turn-N-of-M continuation marker rides on
|
|
355
|
+
// user.system_requirements (per-turn rules), NOT a mutated prompt.
|
|
356
|
+
async #buildRequestPacket({ initialMessages, persona: defaultPersona, runId, loopId, turnNumber, maxTurns, provider, }) {
|
|
357
|
+
const byRole = (role) => initialMessages.filter((m) => m.role === role).map((m) => m.content).join("\n\n");
|
|
358
|
+
const system_definition = byRole("system");
|
|
359
|
+
const prompt = byRole("user");
|
|
360
|
+
// Resolve persona cascade: loops.persona > runs.persona >
|
|
361
|
+
// sessions.persona > caller-supplied default. SQL coalesces in one
|
|
362
|
+
// query; null result means no DB override exists, use the default.
|
|
363
|
+
const row = await this.#db.engine_resolve_persona.get({ loop_id: loopId });
|
|
364
|
+
const persona = (row?.persona !== undefined && row?.persona !== null) ? row.persona : defaultPersona;
|
|
365
|
+
const index = await this.#buildIndex(runId);
|
|
366
|
+
const log = await this.#buildLog(loopId);
|
|
367
|
+
const telemetryErrors = await this.#buildTelemetryErrors(loopId);
|
|
368
|
+
// Rummy AgentLoop.js #buildContinuationPrompt: literally
|
|
369
|
+
// `Turn ${turn}/${maxTurns}`. That's the whole string. The model
|
|
370
|
+
// can read the action log to see what it already did; it does
|
|
371
|
+
// not need editorial instructions from us about not repeating.
|
|
372
|
+
const system_requirements = turnNumber > 1
|
|
373
|
+
? `Turn ${turnNumber}/${maxTurns}`
|
|
374
|
+
: "";
|
|
375
|
+
// Per-section render-cost subtotals via provider's tokenizer.
|
|
376
|
+
// Engine approximates each section by tokenizing its serialized
|
|
377
|
+
// form — wire-payload tokens may differ slightly because chat-
|
|
378
|
+
// template scaffolding adds bytes, but the subtotal tracks "what
|
|
379
|
+
// the model has to process" closely enough for budget diagnostics.
|
|
380
|
+
const systemTokens = provider.countTokens(system_definition) +
|
|
381
|
+
provider.countTokens(persona) +
|
|
382
|
+
provider.countTokens(JSON.stringify(index)) +
|
|
383
|
+
provider.countTokens(JSON.stringify(log));
|
|
384
|
+
const userTokens = provider.countTokens(prompt) +
|
|
385
|
+
provider.countTokens(system_requirements) +
|
|
386
|
+
provider.countTokens(JSON.stringify(telemetryErrors));
|
|
387
|
+
return {
|
|
388
|
+
system: {
|
|
389
|
+
tokens: systemTokens,
|
|
390
|
+
system_definition,
|
|
391
|
+
persona,
|
|
392
|
+
index,
|
|
393
|
+
log,
|
|
394
|
+
},
|
|
395
|
+
user: {
|
|
396
|
+
tokens: userTokens,
|
|
397
|
+
prompt,
|
|
398
|
+
telemetry: { budget: "", errors: telemetryErrors },
|
|
399
|
+
system_requirements,
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
// Wire projection lives in ./packet-wire.js (plain JS) so Engine and
|
|
404
|
+
// bin/digest.js import the exact same function — structurally one
|
|
405
|
+
// implementation, no drift between wire and digest possible.
|
|
406
|
+
// Format: markdown (user pick over rummy's XML alternative, 2026-05-22).
|
|
407
|
+
#packetToWireMessages(packet) {
|
|
408
|
+
return packetToWireMessages(packet);
|
|
409
|
+
}
|
|
410
|
+
// Complete the packet by adding the model's response. After this the
|
|
411
|
+
// packet matches Packet.json fully and is ready for storage.
|
|
412
|
+
#completePacket(requestPacket, assistant, assistantRaw, provider) {
|
|
413
|
+
const assistantTokens = provider.countTokens(assistant.content);
|
|
414
|
+
return {
|
|
415
|
+
tokens: requestPacket.system.tokens + requestPacket.user.tokens + assistantTokens,
|
|
416
|
+
system: requestPacket.system,
|
|
417
|
+
user: requestPacket.user,
|
|
418
|
+
assistant,
|
|
419
|
+
assistantRaw,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
// Render-time mimetype invocation (SPEC §4 {§4-handlers-fire-render-time},
|
|
423
|
+
// §5.1 {§5.1-preview-is-handler-output}). For each (run, entry, channel)
|
|
424
|
+
// with indexed=1, pass the channel's current content through
|
|
425
|
+
// mimetype.preview(content, budget). State is included verbatim — engine
|
|
426
|
+
// does NOT branch on it (§5.6 {§5.6-engine-does-not-branch-on-state}).
|
|
427
|
+
// SPEC §15.1: model-facing alert surface.
|
|
428
|
+
// Two sources, merged on each packet build:
|
|
429
|
+
// 1. Previous-turn action-bound failures (status_rx >= 400 on log_entries).
|
|
430
|
+
// 2. Engine-buffered actionless failures (no_send, parse, watchdog, rails).
|
|
431
|
+
// Buffer drains on read — each error appears in exactly one packet.
|
|
432
|
+
async #buildTelemetryErrors(loopId) {
|
|
433
|
+
const rows = await this.#db.engine_render_telemetry_errors.all({ loop_id: loopId });
|
|
434
|
+
const actionFailures = rows.map((r) => {
|
|
435
|
+
const target = r.target_scheme !== null
|
|
436
|
+
? `${r.target_scheme}://${r.target_pathname ?? ""}`
|
|
437
|
+
: (r.target_pathname ?? null);
|
|
438
|
+
const parsedRx = r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx;
|
|
439
|
+
return {
|
|
440
|
+
kind: "action_failure",
|
|
441
|
+
coordinate: `${r.loop_seq}/${r.turn_seq}/${r.action_index}`,
|
|
442
|
+
op: r.op,
|
|
443
|
+
target,
|
|
444
|
+
status: r.status_rx,
|
|
445
|
+
message: typeof parsedRx === "object" && parsedRx !== null && "error" in parsedRx
|
|
446
|
+
? parsedRx.error
|
|
447
|
+
: typeof parsedRx === "string" ? parsedRx : "",
|
|
448
|
+
};
|
|
449
|
+
});
|
|
450
|
+
return [...this.#drainTelemetry(loopId), ...actionFailures];
|
|
451
|
+
}
|
|
452
|
+
// SPEC §15 packet.system.log — chronological action-entries for the loop.
|
|
453
|
+
// Snapshot is taken at packet build (pre-dispatch this turn), so it
|
|
454
|
+
// reflects "what has happened before this turn." Each row carries a
|
|
455
|
+
// log://<loop_seq>/<turn_seq>/<action_index> coordinate the model can READ.
|
|
456
|
+
async #buildLog(loopId) {
|
|
457
|
+
const rows = await this.#db.engine_render_log.all({ loop_id: loopId });
|
|
458
|
+
return rows.map((r) => ({
|
|
459
|
+
coordinate: `${r.loop_seq}/${r.turn_seq}/${r.action_index}`,
|
|
460
|
+
origin: r.origin,
|
|
461
|
+
op: r.op,
|
|
462
|
+
suffix: r.suffix,
|
|
463
|
+
signal: r.signal === null ? null : JSON.parse(r.signal),
|
|
464
|
+
target: {
|
|
465
|
+
scheme: r.target_scheme,
|
|
466
|
+
username: r.target_username, password: r.target_password,
|
|
467
|
+
hostname: r.target_hostname, port: r.target_port,
|
|
468
|
+
pathname: r.target_pathname,
|
|
469
|
+
params: r.target_params === null ? null : JSON.parse(r.target_params),
|
|
470
|
+
fragment: r.target_fragment,
|
|
471
|
+
},
|
|
472
|
+
status: r.status_rx,
|
|
473
|
+
rx: r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx,
|
|
474
|
+
mimetype_rx: r.mimetype_rx,
|
|
475
|
+
}));
|
|
476
|
+
}
|
|
477
|
+
async #buildIndex(runId) {
|
|
478
|
+
const rows = await this.#db.engine_render_index.all({ run_id: runId });
|
|
479
|
+
const tagsStmt = this.#db.engine_entry_tags;
|
|
480
|
+
const entries = new Map();
|
|
481
|
+
for (const row of rows) {
|
|
482
|
+
let entry = entries.get(row.entry_id);
|
|
483
|
+
if (entry === undefined) {
|
|
484
|
+
const tagRows = await tagsStmt.all({ entry_id: row.entry_id });
|
|
485
|
+
entry = {
|
|
486
|
+
id: row.entry_id,
|
|
487
|
+
version: row.version,
|
|
488
|
+
scope: row.scope,
|
|
489
|
+
session_id: row.session_id,
|
|
490
|
+
scheme: row.scheme,
|
|
491
|
+
username: row.username,
|
|
492
|
+
password: row.password,
|
|
493
|
+
hostname: row.hostname,
|
|
494
|
+
port: row.port,
|
|
495
|
+
pathname: row.pathname,
|
|
496
|
+
params: row.params === null ? null : JSON.parse(row.params),
|
|
497
|
+
channels: {},
|
|
498
|
+
attributes: JSON.parse(row.attributes),
|
|
499
|
+
tags: tagRows.map((r) => r.tag),
|
|
500
|
+
};
|
|
501
|
+
entries.set(row.entry_id, entry);
|
|
502
|
+
}
|
|
503
|
+
// Mimetypes.process owns the full preview pipeline: detect (or
|
|
504
|
+
// honor the hint), resolve handler, validate, extract → symbols,
|
|
505
|
+
// budget-truncate via the framework's fit/fitContent. Passing
|
|
506
|
+
// `hint: row.mimetype` short-circuits detection — service already
|
|
507
|
+
// knows what each channel is.
|
|
508
|
+
const result = await this.#mimetypes.process({ content: row.content, hint: row.mimetype }, { budget: this.#previewBudget });
|
|
509
|
+
entry.channels[row.channel] = {
|
|
510
|
+
content: result.preview,
|
|
511
|
+
mimetype: row.mimetype,
|
|
512
|
+
tokens: row.tokens,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
return [...entries.values()];
|
|
516
|
+
}
|
|
517
|
+
async dispatch(context) {
|
|
518
|
+
const { statement, sessionId, runId, loopId, turnId, actionIndex, origin, onDispatch } = context;
|
|
519
|
+
const schemeCtx = {
|
|
520
|
+
db: this.#db,
|
|
521
|
+
sessionId, runId, loopId, turnId,
|
|
522
|
+
writer: origin,
|
|
523
|
+
signal: undefined,
|
|
524
|
+
};
|
|
525
|
+
let result;
|
|
526
|
+
const denial = this.#checkWritable(statement, origin);
|
|
527
|
+
if (denial !== null) {
|
|
528
|
+
result = denial;
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
// SCHEMES.md §7.1 / §8: action-entry-as-outcome. Scheme-handler
|
|
532
|
+
// exceptions become the action-entry's outcome (status 500), not a
|
|
533
|
+
// thrown bubble. The log_entry is the durable record; engine never
|
|
534
|
+
// skips it. Logging failures (#writeLog throws) are NOT caught —
|
|
535
|
+
// those are system failures.
|
|
536
|
+
try {
|
|
537
|
+
if (statement.op === "SEND" && statement.path === null) {
|
|
538
|
+
result = await this.#handleSendBroadcast(statement, loopId);
|
|
539
|
+
}
|
|
540
|
+
else if (statement.op === "COPY") {
|
|
541
|
+
result = await this.#handleCopy(statement, schemeCtx);
|
|
542
|
+
}
|
|
543
|
+
else if (statement.op === "MOVE") {
|
|
544
|
+
result = await this.#handleMove(statement, schemeCtx);
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
result = await this.#run(this.#schemeNameOf(statement.path), statement, schemeCtx);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
catch (err) {
|
|
551
|
+
result = {
|
|
552
|
+
status: 500,
|
|
553
|
+
error: err instanceof Error ? err.message : String(err),
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const logEntryId = await this.#writeLog({ statement, result, runId, loopId, turnId, actionIndex, origin });
|
|
558
|
+
onDispatch?.(logEntryId);
|
|
559
|
+
// Proposal lifecycle (task #42, AGENTS.md §Phase E). When a scheme
|
|
560
|
+
// returns status 202, the entry is written as state='proposed';
|
|
561
|
+
// dispatch then PAUSES on a per-entry waiter until resolution
|
|
562
|
+
// arrives via Engine.resolveProposal (from the loop/resolve RPC,
|
|
563
|
+
// YOLO listener, or timeout — Phase E.2/E.3 work). The post-
|
|
564
|
+
// resolution status replaces 202 in the result the caller sees,
|
|
565
|
+
// so runTurn never branches on a pending state.
|
|
566
|
+
if (result.status === 202) {
|
|
567
|
+
// Register the resolution waiter SYNCHRONOUSLY before any await
|
|
568
|
+
// yields. A same-tick resolveProposal() (e.g. from a test that
|
|
569
|
+
// awaits the onDispatch callback and immediately resolves) must
|
|
570
|
+
// find the waiter registered — adding an await between insert
|
|
571
|
+
// and waiter-registration would open a race window.
|
|
572
|
+
const resolutionPromise = this.#awaitResolution(logEntryId);
|
|
573
|
+
// Notify external listeners (Daemon broadcasts loop/proposal;
|
|
574
|
+
// YOLO listener auto-resolves) BEFORE awaiting — they may
|
|
575
|
+
// resolve synchronously inside their handlers.
|
|
576
|
+
const target = this.#extractTarget(statement.path);
|
|
577
|
+
const flags = await this.#loadLoopFlags(loopId);
|
|
578
|
+
const event = {
|
|
579
|
+
logEntryId, sessionId, runId, loopId, turnId,
|
|
580
|
+
op: statement.op,
|
|
581
|
+
target: { scheme: target.scheme, pathname: target.pathname },
|
|
582
|
+
body: typeof result.body === "string" ? result.body : "",
|
|
583
|
+
attrs: (result.attrs ?? {}),
|
|
584
|
+
flags,
|
|
585
|
+
};
|
|
586
|
+
for (const listener of this.#proposalPendingListeners) {
|
|
587
|
+
try {
|
|
588
|
+
listener(event);
|
|
589
|
+
}
|
|
590
|
+
catch (_) { /* listener errors don't break dispatch */ }
|
|
591
|
+
}
|
|
592
|
+
const resolution = await resolutionPromise;
|
|
593
|
+
// Run the scheme's applyResolution hook on accept (writes the
|
|
594
|
+
// file, spawns the process, etc.). If applyResolution returns a
|
|
595
|
+
// 4xx/5xx or throws, the resolution is downgraded to a reject
|
|
596
|
+
// with the failure outcome — engine treats it like a client
|
|
597
|
+
// rejection.
|
|
598
|
+
const effective = await this.#runApplyResolution(statement, result, resolution);
|
|
599
|
+
const post = await this.#applyResolution(logEntryId, effective);
|
|
600
|
+
return post;
|
|
601
|
+
}
|
|
602
|
+
return result;
|
|
603
|
+
}
|
|
604
|
+
async #runApplyResolution(statement, originalResult, resolution) {
|
|
605
|
+
if (resolution.decision !== "accept")
|
|
606
|
+
return resolution;
|
|
607
|
+
const schemeName = this.#schemeNameOf(statement.path);
|
|
608
|
+
if (schemeName === null)
|
|
609
|
+
return resolution;
|
|
610
|
+
const handler = this.#schemes.get(schemeName);
|
|
611
|
+
if (handler === undefined || typeof handler.applyResolution !== "function")
|
|
612
|
+
return resolution;
|
|
613
|
+
try {
|
|
614
|
+
const applyResult = await handler.applyResolution({
|
|
615
|
+
attrs: (originalResult.attrs ?? {}),
|
|
616
|
+
body: resolution.body,
|
|
617
|
+
});
|
|
618
|
+
if (applyResult.status >= 400) {
|
|
619
|
+
return {
|
|
620
|
+
decision: "reject",
|
|
621
|
+
outcome: applyResult.outcome ?? "apply_failed",
|
|
622
|
+
body: applyResult.body,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
return resolution;
|
|
626
|
+
}
|
|
627
|
+
catch (err) {
|
|
628
|
+
return {
|
|
629
|
+
decision: "reject",
|
|
630
|
+
outcome: "apply_threw",
|
|
631
|
+
body: err instanceof Error ? err.message : String(err),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
// Engine.resolveProposal: external API to feed a resolution into a
|
|
636
|
+
// pending proposal. Called by the loop/resolve RPC handler (Phase E.2),
|
|
637
|
+
// the in-tree YOLO listener (Phase E.3), or the timeout watcher. Throws
|
|
638
|
+
// when the logEntryId has no pending waiter — duplicate resolutions, IDs
|
|
639
|
+
// for non-proposed entries, or entries already-resolved are caller
|
|
640
|
+
// errors.
|
|
641
|
+
resolveProposal(logEntryId, resolution) {
|
|
642
|
+
const waiter = this.#pendingProposals.get(logEntryId);
|
|
643
|
+
if (waiter === undefined) {
|
|
644
|
+
throw new Error(`Engine.resolveProposal: no pending proposal for log_entry ${logEntryId}`);
|
|
645
|
+
}
|
|
646
|
+
clearTimeout(waiter.timeoutHandle);
|
|
647
|
+
this.#pendingProposals.delete(logEntryId);
|
|
648
|
+
waiter.resolve(resolution);
|
|
649
|
+
}
|
|
650
|
+
// Snapshot of pending proposals (for diagnostic / RPC listings). Returns
|
|
651
|
+
// the log entry IDs currently awaiting resolution.
|
|
652
|
+
pendingProposalIds() {
|
|
653
|
+
return [...this.#pendingProposals.keys()];
|
|
654
|
+
}
|
|
655
|
+
// Subscribe to proposal-pending events. Daemon registers a listener
|
|
656
|
+
// that broadcasts the loop/proposal WS notification; YOLO listener
|
|
657
|
+
// (Phase E.3) registers one that auto-resolves. Listeners fire BEFORE
|
|
658
|
+
// dispatch awaits resolution, so synchronous (or fast-async) handlers
|
|
659
|
+
// can resolve inline.
|
|
660
|
+
onProposalPending(listener) {
|
|
661
|
+
this.#proposalPendingListeners.push(listener);
|
|
662
|
+
}
|
|
663
|
+
// Loads loops.flags (json column) and merges over DEFAULT_LOOP_FLAGS so
|
|
664
|
+
// missing keys read as their documented defaults. Single read site —
|
|
665
|
+
// ProposalPendingEvent.flags is constructed from this, and listeners
|
|
666
|
+
// (Daemon broadcast, YOLO auto-accept) share the result.
|
|
667
|
+
async #loadLoopFlags(loopId) {
|
|
668
|
+
const row = await this.#db.engine_get_loop_flags.get({ loop_id: loopId });
|
|
669
|
+
if (row === undefined)
|
|
670
|
+
return DEFAULT_LOOP_FLAGS;
|
|
671
|
+
try {
|
|
672
|
+
const parsed = JSON.parse(row.flags);
|
|
673
|
+
return { ...DEFAULT_LOOP_FLAGS, ...parsed };
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
return DEFAULT_LOOP_FLAGS;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
#awaitResolution(logEntryId) {
|
|
680
|
+
const timeoutMs = readProposalTimeoutMs();
|
|
681
|
+
return new Promise((resolve) => {
|
|
682
|
+
const timeoutHandle = setTimeout(() => {
|
|
683
|
+
// Timeout: synthesize a cancel resolution and feed it back
|
|
684
|
+
// through the same path as any other resolution. State
|
|
685
|
+
// transitions to cancelled with outcome='timeout'.
|
|
686
|
+
if (this.#pendingProposals.has(logEntryId)) {
|
|
687
|
+
this.#pendingProposals.delete(logEntryId);
|
|
688
|
+
resolve({ decision: "cancel", outcome: "timeout" });
|
|
689
|
+
}
|
|
690
|
+
}, timeoutMs);
|
|
691
|
+
this.#pendingProposals.set(logEntryId, { resolve, timeoutHandle });
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
async #applyResolution(logEntryId, resolution) {
|
|
695
|
+
// Map decision → terminal state + HTTP-aligned status:
|
|
696
|
+
// accept → state='resolved', status=200
|
|
697
|
+
// reject → state='failed', status=400, outcome='rejected' (default)
|
|
698
|
+
// cancel → state='cancelled',status=499, outcome='loop_aborted' (default)
|
|
699
|
+
// resolution.outcome wins over the default when supplied; this is how
|
|
700
|
+
// veto filters (Phase E.2 proposal.accepting) can specify a more
|
|
701
|
+
// precise outcome string like 'policy_veto' or 'timeout'.
|
|
702
|
+
const decision = resolution.decision;
|
|
703
|
+
const state = decision === "accept" ? "resolved"
|
|
704
|
+
: decision === "reject" ? "failed"
|
|
705
|
+
: "cancelled";
|
|
706
|
+
const status = decision === "accept" ? 200
|
|
707
|
+
: decision === "reject" ? 400
|
|
708
|
+
: 499;
|
|
709
|
+
const defaultOutcome = decision === "accept" ? null
|
|
710
|
+
: decision === "reject" ? "rejected"
|
|
711
|
+
: "loop_aborted";
|
|
712
|
+
const outcome = resolution.outcome ?? defaultOutcome;
|
|
713
|
+
const rx = JSON.stringify({ status, outcome, body: resolution.body ?? null });
|
|
714
|
+
await this.#db.engine_resolve_log_entry.run({
|
|
715
|
+
id: logEntryId, state, outcome, status_rx: status, rx,
|
|
716
|
+
});
|
|
717
|
+
return { status, outcome, body: resolution.body };
|
|
718
|
+
}
|
|
719
|
+
// SCHEMES.md §8 {§8-writable-by-enforcement}: engine rejects writes whose
|
|
720
|
+
// origin is outside the target scheme's manifest.writableBy.
|
|
721
|
+
// - Read-side ops (READ, FIND, SHOW, HIDE) are not gated.
|
|
722
|
+
// - SEND broadcast (path=null) has no target scheme; not gated.
|
|
723
|
+
// - COPY: dst scheme writableBy applies.
|
|
724
|
+
// - MOVE: both src (delete) and dst (write) schemes' writableBy apply.
|
|
725
|
+
// - Schemes without a manifest are not gated (legacy / future allowance).
|
|
726
|
+
#checkWritable(statement, origin) {
|
|
727
|
+
if (!MUTATING_OPS.has(statement.op))
|
|
728
|
+
return null;
|
|
729
|
+
if (statement.op === "SEND" && statement.path === null)
|
|
730
|
+
return null;
|
|
731
|
+
if (statement.op === "COPY" || statement.op === "MOVE") {
|
|
732
|
+
const dstScheme = this.#schemeNameOf(statement.body);
|
|
733
|
+
const dstDenial = this.#denyIfDisallowed(dstScheme, origin);
|
|
734
|
+
if (dstDenial !== null)
|
|
735
|
+
return dstDenial;
|
|
736
|
+
if (statement.op === "MOVE") {
|
|
737
|
+
const srcScheme = this.#schemeNameOf(statement.path);
|
|
738
|
+
if (srcScheme !== dstScheme) {
|
|
739
|
+
const srcDenial = this.#denyIfDisallowed(srcScheme, origin);
|
|
740
|
+
if (srcDenial !== null)
|
|
741
|
+
return srcDenial;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return null;
|
|
745
|
+
}
|
|
746
|
+
const target = this.#schemeNameOf(statement.path);
|
|
747
|
+
return this.#denyIfDisallowed(target, origin);
|
|
748
|
+
}
|
|
749
|
+
#denyIfDisallowed(schemeName, origin) {
|
|
750
|
+
if (schemeName === null)
|
|
751
|
+
return null;
|
|
752
|
+
const handler = this.#schemes.get(schemeName);
|
|
753
|
+
if (handler === undefined)
|
|
754
|
+
return null;
|
|
755
|
+
const manifest = handler.constructor.manifest;
|
|
756
|
+
if (manifest === undefined)
|
|
757
|
+
return null;
|
|
758
|
+
if (manifest.writableBy.includes(origin))
|
|
759
|
+
return null;
|
|
760
|
+
return { status: 403, error: `writer '${origin}' is not in writableBy for scheme '${schemeName}'` };
|
|
761
|
+
}
|
|
762
|
+
async #handleCopy(statement, ctx) {
|
|
763
|
+
if (statement.op !== "COPY")
|
|
764
|
+
throw new Error("unreachable");
|
|
765
|
+
const srcPath = statement.path;
|
|
766
|
+
const dstPath = statement.body;
|
|
767
|
+
if (srcPath === null)
|
|
768
|
+
return { status: 400, error: "COPY requires source path" };
|
|
769
|
+
if (dstPath === null)
|
|
770
|
+
return { status: 400, error: "COPY requires destination path (in body slot)" };
|
|
771
|
+
return await this.#copyOrchestration({ statement, srcPath, dstPath, ctx });
|
|
772
|
+
}
|
|
773
|
+
async #handleMove(statement, ctx) {
|
|
774
|
+
if (statement.op !== "MOVE")
|
|
775
|
+
throw new Error("unreachable");
|
|
776
|
+
const srcPath = statement.path;
|
|
777
|
+
const dstPath = statement.body;
|
|
778
|
+
if (srcPath === null)
|
|
779
|
+
return { status: 400, error: "MOVE requires source path" };
|
|
780
|
+
const srcSchemeName = this.#schemeNameOf(srcPath);
|
|
781
|
+
if (srcSchemeName === null)
|
|
782
|
+
return { status: 400, error: "MOVE source must be a URL path with a scheme" };
|
|
783
|
+
const srcHandler = this.#schemes.get(srcSchemeName);
|
|
784
|
+
if (srcHandler === undefined || typeof srcHandler.deleteEntry !== "function")
|
|
785
|
+
return { status: 501 };
|
|
786
|
+
// Null-body MOVE = delete the source entry (per SPEC §6.5)
|
|
787
|
+
if (dstPath === null) {
|
|
788
|
+
const srcPathname = pathnameFromPath(srcPath);
|
|
789
|
+
const delResult = await srcHandler.deleteEntry(srcPathname, ctx);
|
|
790
|
+
return { status: delResult.status };
|
|
791
|
+
}
|
|
792
|
+
// Relocation: COPY then DELETE source
|
|
793
|
+
const copyResult = await this.#copyOrchestration({ statement, srcPath, dstPath, ctx });
|
|
794
|
+
if (copyResult.status >= 400)
|
|
795
|
+
return copyResult;
|
|
796
|
+
const srcPathname = pathnameFromPath(srcPath);
|
|
797
|
+
const delResult = await srcHandler.deleteEntry(srcPathname, ctx);
|
|
798
|
+
if (delResult.status >= 400)
|
|
799
|
+
return { status: delResult.status };
|
|
800
|
+
return copyResult;
|
|
801
|
+
}
|
|
802
|
+
async #copyOrchestration({ statement, srcPath, dstPath, ctx }) {
|
|
803
|
+
const srcSchemeName = this.#schemeNameOf(srcPath);
|
|
804
|
+
const dstSchemeName = this.#schemeNameOf(dstPath);
|
|
805
|
+
if (srcSchemeName === null || dstSchemeName === null)
|
|
806
|
+
return { status: 400, error: "COPY/MOVE require URL paths with schemes" };
|
|
807
|
+
const srcHandler = this.#schemes.get(srcSchemeName);
|
|
808
|
+
const dstHandler = this.#schemes.get(dstSchemeName);
|
|
809
|
+
if (srcHandler === undefined || dstHandler === undefined)
|
|
810
|
+
return { status: 501 };
|
|
811
|
+
if (typeof srcHandler.readEntry !== "function" || typeof dstHandler.writeEntry !== "function")
|
|
812
|
+
return { status: 501 };
|
|
813
|
+
const srcPathname = pathnameFromPath(srcPath);
|
|
814
|
+
const dstPathname = pathnameFromPath(dstPath);
|
|
815
|
+
const srcResult = await srcHandler.readEntry(srcPathname, ctx);
|
|
816
|
+
if (srcResult.status !== 200 || srcResult.entry === null)
|
|
817
|
+
return { status: 404, error: `COPY/MOVE source not found: ${srcSchemeName}://${srcPathname}` };
|
|
818
|
+
const entry = srcResult.entry;
|
|
819
|
+
// Conflict check on destination
|
|
820
|
+
if (typeof dstHandler.readEntry === "function") {
|
|
821
|
+
const dstExists = await dstHandler.readEntry(dstPathname, ctx);
|
|
822
|
+
if (dstExists.status === 200)
|
|
823
|
+
return { status: 409, error: `COPY/MOVE destination exists: ${dstSchemeName}://${dstPathname}` };
|
|
824
|
+
}
|
|
825
|
+
// Mimetype compatibility check against the destination scheme's manifest
|
|
826
|
+
const dstManifest = dstHandler.constructor.manifest;
|
|
827
|
+
const dstChannels = dstManifest?.channels ?? {};
|
|
828
|
+
for (const [channelName, channelData] of Object.entries(entry.channels)) {
|
|
829
|
+
const expectedMimetype = dstChannels[channelName];
|
|
830
|
+
if (expectedMimetype !== undefined && expectedMimetype !== channelData.mimetype) {
|
|
831
|
+
return { status: 415, error: `mimetype mismatch on channel '${channelName}': ${channelData.mimetype} vs ${expectedMimetype}` };
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
// Tag resolution: signal = replace; absent/empty = carry from source
|
|
835
|
+
const tags = (Array.isArray(statement.signal) && statement.signal.length > 0)
|
|
836
|
+
? statement.signal
|
|
837
|
+
: entry.tags;
|
|
838
|
+
const writeResult = await dstHandler.writeEntry(dstPathname, { channels: entry.channels, tags }, ctx);
|
|
839
|
+
return { status: writeResult.status, entryId: writeResult.entryId, created: writeResult.created };
|
|
840
|
+
}
|
|
841
|
+
async #handleSendBroadcast(statement, loopId) {
|
|
842
|
+
if (statement.op !== "SEND")
|
|
843
|
+
throw new Error("unreachable");
|
|
844
|
+
const status = statement.signal;
|
|
845
|
+
if (status === null)
|
|
846
|
+
return { status: 400 };
|
|
847
|
+
if (status === 200 || status === 499) {
|
|
848
|
+
await this.#db.engine_loop_set_status.run({ status, loop_id: loopId });
|
|
849
|
+
}
|
|
850
|
+
return { status };
|
|
851
|
+
}
|
|
852
|
+
async #run(schemeName, statement, ctx) {
|
|
853
|
+
if (schemeName === null)
|
|
854
|
+
return { status: 400 };
|
|
855
|
+
const handler = this.#schemes.get(schemeName);
|
|
856
|
+
if (handler === undefined)
|
|
857
|
+
return { status: 501 };
|
|
858
|
+
const methodName = statement.op.toLowerCase();
|
|
859
|
+
const method = handler[methodName];
|
|
860
|
+
if (typeof method !== "function")
|
|
861
|
+
return { status: 501 };
|
|
862
|
+
return method.call(handler, statement, ctx);
|
|
863
|
+
}
|
|
864
|
+
// Bare paths default to the file scheme per plurnk.md (grammar sysprompt):
|
|
865
|
+
// "Bare paths (no scheme) default to local relative project file paths."
|
|
866
|
+
// file:// remains an optional explicit form for absolute paths.
|
|
867
|
+
#schemeNameOf(path) {
|
|
868
|
+
if (path === null)
|
|
869
|
+
return null;
|
|
870
|
+
if (path.kind === "url")
|
|
871
|
+
return path.scheme;
|
|
872
|
+
return "file"; // local (bare) → file
|
|
873
|
+
}
|
|
874
|
+
async #writeLog({ statement, result, runId, loopId, turnId, actionIndex, origin, }) {
|
|
875
|
+
const target = this.#extractTarget(statement.path);
|
|
876
|
+
const lineMarkerJson = "lineMarker" in statement && statement.lineMarker !== null
|
|
877
|
+
? JSON.stringify(statement.lineMarker)
|
|
878
|
+
: null;
|
|
879
|
+
// Status 202 from a scheme means the action is proposed — written to
|
|
880
|
+
// the log in state='proposed' until the proposal lifecycle resolves
|
|
881
|
+
// it. attrs holds the scheme-supplied payload (file diff, exec
|
|
882
|
+
// command, etc.) that the client renders for review and the scheme
|
|
883
|
+
// consumes on accept. All other statuses are terminal — state =
|
|
884
|
+
// 'resolved' for the common case.
|
|
885
|
+
const isProposed = result.status === 202;
|
|
886
|
+
const attrs = (result.attrs !== undefined && result.attrs !== null)
|
|
887
|
+
? JSON.stringify(result.attrs)
|
|
888
|
+
: "{}";
|
|
889
|
+
const row = await this.#db.engine_insert_log_entry.get({
|
|
890
|
+
run_id: runId,
|
|
891
|
+
loop_id: loopId,
|
|
892
|
+
turn_id: turnId,
|
|
893
|
+
action_index: actionIndex,
|
|
894
|
+
origin,
|
|
895
|
+
op: statement.op,
|
|
896
|
+
suffix: statement.suffix,
|
|
897
|
+
signal: this.#signalToJson(statement.signal),
|
|
898
|
+
target_scheme: target.scheme,
|
|
899
|
+
target_username: target.username,
|
|
900
|
+
target_password: target.password,
|
|
901
|
+
target_hostname: target.hostname,
|
|
902
|
+
target_port: target.port,
|
|
903
|
+
target_pathname: target.pathname,
|
|
904
|
+
target_params: target.params,
|
|
905
|
+
target_fragment: target.fragment,
|
|
906
|
+
lineMarker: lineMarkerJson,
|
|
907
|
+
tx: JSON.stringify(statement),
|
|
908
|
+
mimetype_tx: "application/json",
|
|
909
|
+
rx: JSON.stringify(result),
|
|
910
|
+
mimetype_rx: "application/json",
|
|
911
|
+
status_rx: result.status,
|
|
912
|
+
state: isProposed ? "proposed" : "resolved",
|
|
913
|
+
outcome: null,
|
|
914
|
+
attrs,
|
|
915
|
+
});
|
|
916
|
+
if (row === undefined)
|
|
917
|
+
throw new Error("Engine.#writeLog: INSERT ... RETURNING produced no row");
|
|
918
|
+
return row.id;
|
|
919
|
+
}
|
|
920
|
+
#extractTarget(path) {
|
|
921
|
+
if (path === null)
|
|
922
|
+
return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: null, params: null, fragment: null };
|
|
923
|
+
if (path.kind === "local")
|
|
924
|
+
return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: path.raw, params: null, fragment: null };
|
|
925
|
+
return {
|
|
926
|
+
scheme: path.scheme, username: path.username, password: path.password,
|
|
927
|
+
hostname: path.hostname, port: path.port, pathname: path.pathname,
|
|
928
|
+
params: JSON.stringify(path.params), fragment: path.fragment,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
#signalToJson(signal) {
|
|
932
|
+
if (signal === null || signal === undefined)
|
|
933
|
+
return null;
|
|
934
|
+
return JSON.stringify(signal);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
//# sourceMappingURL=Engine.js.map
|