@bybrawe/opencode-loop 0.5.38 → 0.6.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/CHANGELOG.md +10 -0
- package/README.md +30 -0
- package/examples/OPENCODE2-0.6.0.md +54 -0
- package/package.json +12 -9
- package/scripts/branch-hygiene.mjs +326 -0
- package/scripts/comprehensive-test.mjs +12 -6
- package/scripts/control-plane-until-test.mjs +39 -0
- package/scripts/goal-compaction-safety-test.mjs +68 -0
- package/scripts/goal-companion-test.mjs +1 -1
- package/scripts/host-adapter-contract-test.mjs +1 -0
- package/scripts/install-node.mjs +105 -60
- package/scripts/install-test.mjs +3 -3
- package/scripts/install-with-goals.mjs +17 -4
- package/scripts/job-workspace-test.mjs +11 -1
- package/scripts/loopd-test.mjs +74 -4
- package/scripts/loopd.mjs +153 -25
- package/scripts/native-cli-host-canary.mjs +118 -0
- package/scripts/native-cli-io-test.mjs +66 -0
- package/scripts/native-cli-test.mjs +130 -0
- package/scripts/native-coexistence-test.mjs +38 -0
- package/scripts/native-entry-test.mjs +77 -0
- package/scripts/native-guide-test.mjs +45 -0
- package/scripts/native-install-pair-test.mjs +73 -0
- package/scripts/native-install-test.mjs +107 -0
- package/scripts/native-lifecycle-test.mjs +100 -0
- package/scripts/native-plugin-lifetime-test.mjs +92 -0
- package/scripts/native-presets-test.mjs +227 -0
- package/scripts/native-process-tree-fixture.mjs +72 -0
- package/scripts/native-process-tree-test.mjs +40 -0
- package/scripts/native-release.mjs +121 -0
- package/scripts/native-session-location-test.mjs +86 -0
- package/scripts/native-shell-test.mjs +38 -0
- package/scripts/native-v2-test.mjs +262 -0
- package/scripts/opencode-host.mjs +47 -0
- package/scripts/opencode2-loop-canary.mjs +72 -4
- package/scripts/package-native-smoke.mjs +31 -0
- package/scripts/publish-workflow-test.mjs +60 -19
- package/scripts/pulse-check.mjs +246 -0
- package/scripts/session-status-test.mjs +12 -0
- package/src/index.js +87 -48
- package/src/native.js +3135 -0
- package/src/server.js +2164 -4560
- package/src/source/core/jobs.js +17 -0
- package/src/source/core/process.js +6 -4
- package/src/source/core/progress.js +58 -0
- package/src/source/legacy-v1.js +9 -3
- package/src/source/native.js +1 -0
- package/src/source/opencode/host.js +1 -0
- package/src/source/opencode/loop-commands.js +1 -27
- package/src/source/opencode/loop-registration.js +2 -12
- package/src/source/opencode/messages.js +1 -1
- package/src/source/opencode2/commands.js +9 -4
- package/src/source/opencode2/event-bridge.js +15 -3
- package/src/source/opencode2/events.js +20 -0
- package/src/source/opencode2/experimental.js +2 -0
- package/src/source/opencode2/native-companion.js +24 -0
- package/src/source/opencode2/native-plugin.js +153 -0
- package/src/source/opencode2/native-policy.js +106 -0
- package/src/source/opencode2/native-runtime.js +464 -0
- package/src/source/opencode2/native-shell.js +81 -0
- package/src/source/opencode2/prompt-runtime.js +2 -0
- package/src/source/opencode2/session-location.js +16 -0
- package/src/source/runtime/goal-steering.js +17 -3
- package/src/source/runtime/job-workspace.js +11 -2
- package/src/source/runtime/session-status.js +11 -1
- package/src/source/server.js +9 -1
- package/src/v1.js +1 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { createNativeLoopRuntime } from "../src/source/opencode2/native-runtime.js"
|
|
6
|
+
import { normalizeOpenCode2NativeEvent } from "../src/source/opencode2/events.js"
|
|
7
|
+
import { createOpenCode2EventBridge } from "../src/source/opencode2/event-bridge.js"
|
|
8
|
+
import { readState } from "../src/source/core/state.js"
|
|
9
|
+
|
|
10
|
+
let cases = 0
|
|
11
|
+
async function fixture(extra = {}) {
|
|
12
|
+
const directory = await mkdtemp(path.join(tmpdir(), "loop-native-"))
|
|
13
|
+
const scope = { directory, sessionID: "ses-native-test" }
|
|
14
|
+
let clock = 10_000
|
|
15
|
+
const prompts = [], compacts = [], shells = [], commands = [], hooks = [], timers = []
|
|
16
|
+
const runtime = createNativeLoopRuntime({
|
|
17
|
+
directory, now: () => clock,
|
|
18
|
+
prompt: async (request) => { if (request.resume !== false) prompts.push(request); return { id: request.id } },
|
|
19
|
+
compact: async (request) => { compacts.push(request); return { id: request.id } },
|
|
20
|
+
shell: async (request) => { shells.push(request); return { id: request.id, status: "running" } },
|
|
21
|
+
command: async (request) => { commands.push(request) },
|
|
22
|
+
wait: async () => {},
|
|
23
|
+
runShellCommand: async (command) => { hooks.push(command); return { code: 0, stdout: "ok", stderr: "" } },
|
|
24
|
+
setTimer: (fn, delay) => { const timer = { fn, delay }; timers.push(timer); return timer },
|
|
25
|
+
clearTimer: (timer) => { timer.cancelled = true },
|
|
26
|
+
...extra,
|
|
27
|
+
})
|
|
28
|
+
const event = (kind, action, data = {}) => runtime.onEvent({ ...scope, kind, action, ...data })
|
|
29
|
+
return {
|
|
30
|
+
runtime, scope, directory, prompts, compacts, shells, commands, hooks, timers, event,
|
|
31
|
+
add: (args) => event("command", "executed", { name: "loop", arguments: args }),
|
|
32
|
+
control: (name, args = "") => event("command", "executed", { name, arguments: args }),
|
|
33
|
+
wake: () => runtime.wake(scope),
|
|
34
|
+
state: () => readState(directory, scope.sessionID),
|
|
35
|
+
complete: async (index = prompts.length - 1) => {
|
|
36
|
+
await event("inbox", "delivered", { inboxID: prompts[index].id })
|
|
37
|
+
await event("session", "status", { status: "busy" })
|
|
38
|
+
return event("session", "idle")
|
|
39
|
+
},
|
|
40
|
+
tick: async (ms) => { clock += ms; const timer = timers.find((entry) => !entry.cancelled); assert.ok(timer, "expected a timer"); timer.cancelled = true; return timer.fn() },
|
|
41
|
+
close: async () => { await runtime.dispose(); await rm(directory, { recursive: true, force: true }) },
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function test(name, run, extra) {
|
|
45
|
+
const f = await fixture(extra)
|
|
46
|
+
try { await run(f); cases++; console.log(`ok ${cases}: ${name}`) }
|
|
47
|
+
finally { await f.close() }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await test("one durable prompt under concurrent idle notifications", async (f) => {
|
|
51
|
+
assert.equal((await f.add("0s keep working --max-runs 2")).accepted, true)
|
|
52
|
+
await Promise.all(Array.from({ length: 12 }, () => f.wake()))
|
|
53
|
+
assert.equal(f.prompts.length, 1)
|
|
54
|
+
assert.equal(f.prompts[0].delivery, "queue")
|
|
55
|
+
assert.equal(f.prompts[0].metadata.opencode_loop_v2, true)
|
|
56
|
+
await f.event("session", "idle")
|
|
57
|
+
assert.equal(f.prompts.length, 1, "an unrelated terminal cannot finish an undelivered inbox")
|
|
58
|
+
await f.complete()
|
|
59
|
+
assert.equal(f.prompts.length, 2)
|
|
60
|
+
await f.complete()
|
|
61
|
+
const job = (await f.state()).jobs[0]
|
|
62
|
+
assert.equal(job.runCount, 2)
|
|
63
|
+
assert.equal(job.enabled, false)
|
|
64
|
+
assert.equal(job.v2Run, undefined)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
for (const order of ["compaction-first", "execution-first"]) {
|
|
68
|
+
await test(`compaction barrier: ${order}`, async (f) => {
|
|
69
|
+
await f.add('0s continue --max-runs 2 --verify "node verify.js"')
|
|
70
|
+
await f.wake()
|
|
71
|
+
await f.event("inbox", "delivered", { inboxID: f.prompts[0].id })
|
|
72
|
+
await f.event("compaction", "started")
|
|
73
|
+
await f.event("message", "updated", { role: "user", messageID: "core-marker" })
|
|
74
|
+
const first = order === "compaction-first" ? ["compaction", "ended"] : ["session", "idle"]
|
|
75
|
+
const second = order === "compaction-first" ? ["session", "idle"] : ["compaction", "ended"]
|
|
76
|
+
await f.event(...first)
|
|
77
|
+
assert.equal(f.prompts.length, 1)
|
|
78
|
+
assert.equal(f.hooks.length, 0)
|
|
79
|
+
await f.event(...second)
|
|
80
|
+
assert.equal(f.hooks.length, 1)
|
|
81
|
+
assert.equal(f.prompts.length, 2)
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await test("rejected or uncertain admission never charges a run or spins", async (f) => {
|
|
86
|
+
await f.add("0s task --max-runs 1")
|
|
87
|
+
await f.wake()
|
|
88
|
+
const job = (await f.state()).jobs[0]
|
|
89
|
+
assert.equal(job.runCount, 0)
|
|
90
|
+
assert.equal(job.enabled, true)
|
|
91
|
+
assert.equal(job.paused, true)
|
|
92
|
+
assert.equal(job.pauseReason, "admission-uncertain")
|
|
93
|
+
assert.ok(job.v2Run.id, "retain the identity of a potentially admitted request")
|
|
94
|
+
await f.event("session", "idle")
|
|
95
|
+
assert.equal((await f.state()).jobs[0].failureCount, 1)
|
|
96
|
+
}, { prompt: async () => { throw new Error("lost response") } })
|
|
97
|
+
|
|
98
|
+
await test("confirmed pending cancellation refunds only the unexecuted run", async (f) => {
|
|
99
|
+
await f.add("0s task --max-runs 1")
|
|
100
|
+
await f.wake()
|
|
101
|
+
await f.control("loop-pause")
|
|
102
|
+
await f.event("inbox", "cancelled", { inboxID: f.prompts[0].id })
|
|
103
|
+
const job = (await f.state()).jobs[0]
|
|
104
|
+
assert.equal(job.runCount, 0)
|
|
105
|
+
assert.equal(job.enabled, true)
|
|
106
|
+
assert.equal(job.paused, true)
|
|
107
|
+
assert.equal(job.v2Run, undefined)
|
|
108
|
+
await f.control("loop-resume")
|
|
109
|
+
await f.wake()
|
|
110
|
+
assert.equal(f.prompts.length, 2)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
await test("foreground admission suppresses verification and continuation", async (f) => {
|
|
114
|
+
await f.add('0s task --max-runs 1 --verify "node verify.js"')
|
|
115
|
+
await f.wake()
|
|
116
|
+
await f.event("inbox", "delivered", { inboxID: f.prompts[0].id })
|
|
117
|
+
await f.event("foreground", "admitted")
|
|
118
|
+
await f.event("session", "idle")
|
|
119
|
+
assert.equal(f.hooks.length, 0)
|
|
120
|
+
assert.equal(f.prompts.length, 1)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
await test("soft timeout cannot abort native compaction", async (f) => {
|
|
124
|
+
await f.add("0s task --max-runs 2 --timeout 10ms")
|
|
125
|
+
await f.wake()
|
|
126
|
+
await f.event("inbox", "delivered", { inboxID: f.prompts[0].id })
|
|
127
|
+
await f.event("compaction", "started")
|
|
128
|
+
await f.tick(11)
|
|
129
|
+
assert.equal((await f.state()).jobs[0].pauseReason, "timeout-waiting-for-host-boundary")
|
|
130
|
+
await f.event("compaction", "ended")
|
|
131
|
+
await f.event("session", "idle")
|
|
132
|
+
assert.equal(f.prompts.length, 1)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
await test("prompt and include files use the native prompt", async (f) => {
|
|
136
|
+
await writeFile(path.join(f.directory, "instructions.md"), "UNIQUE_NATIVE_INSTRUCTIONS")
|
|
137
|
+
await writeFile(path.join(f.directory, "progress.md"), "UNIQUE_NATIVE_CONTEXT")
|
|
138
|
+
await f.add("0s task --prompt-file instructions.md --include-file progress.md --max-runs 1")
|
|
139
|
+
await f.wake()
|
|
140
|
+
assert.match(f.prompts[0].text, /UNIQUE_NATIVE_INSTRUCTIONS/)
|
|
141
|
+
assert.match(f.prompts[0].text, /UNIQUE_NATIVE_CONTEXT/)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
await test("watch baseline does not trigger until the file changes", async (f) => {
|
|
145
|
+
await writeFile(path.join(f.directory, "progress.md"), "initial")
|
|
146
|
+
await f.add("--watch progress.md task --max-runs 1")
|
|
147
|
+
await f.wake()
|
|
148
|
+
assert.equal(f.prompts.length, 0)
|
|
149
|
+
await writeFile(path.join(f.directory, "progress.md"), "changed and longer")
|
|
150
|
+
await f.tick(1000)
|
|
151
|
+
assert.equal(f.prompts.length, 1)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
await test("control state is not --until completion evidence", async (f) => {
|
|
155
|
+
await f.add("0s task --until UNIQUE_COMPLETION_MARKER --max-runs 1")
|
|
156
|
+
await f.wake()
|
|
157
|
+
assert.equal(f.prompts.length, 1)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
await test("stop-file is enforced before a future interval is due", async (f) => {
|
|
161
|
+
await f.add("1h task --no-now --stop-file STOP")
|
|
162
|
+
await writeFile(path.join(f.directory, "STOP"), "stop")
|
|
163
|
+
await f.tick(1000)
|
|
164
|
+
const job = (await f.state()).jobs[0]
|
|
165
|
+
assert.equal(job.enabled, false)
|
|
166
|
+
assert.equal(job.pauseReason, "stop-file")
|
|
167
|
+
assert.equal(f.prompts.length, 0)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
await test("maximum runtime is enforced without waiting for the next interval", async (f) => {
|
|
171
|
+
await f.add("1h task --no-now --max-runtime 5s")
|
|
172
|
+
await f.tick(5001)
|
|
173
|
+
assert.equal((await f.state()).jobs[0].pauseReason, "max-runtime")
|
|
174
|
+
assert.equal(f.prompts.length, 0)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
await test("preflight failure does not consume a run", async (f) => {
|
|
178
|
+
await f.add('0s task --preflight "node check.js" --max-runs 1')
|
|
179
|
+
await f.wake()
|
|
180
|
+
const job = (await f.state()).jobs[0]
|
|
181
|
+
assert.equal(job.runCount, 0)
|
|
182
|
+
assert.equal(job.pauseReason, "preflight-failed")
|
|
183
|
+
assert.equal(f.prompts.length, 0)
|
|
184
|
+
}, { runShellCommand: async () => ({ code: 3, stdout: "", stderr: "not ready" }) })
|
|
185
|
+
|
|
186
|
+
await test("safe guard covers verification commands too", async (f) => {
|
|
187
|
+
await f.add('0s task --safe --verify "git reset --hard" --max-runs 1')
|
|
188
|
+
await f.wake()
|
|
189
|
+
await f.complete()
|
|
190
|
+
assert.equal(f.hooks.length, 0)
|
|
191
|
+
assert.equal((await f.state()).jobs[0].pauseReason, "verification-failed")
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
await test("dry-run never invokes preflight, shell, or the native model", async (f) => {
|
|
195
|
+
await f.add('0s --shell echo preview --dry-run --preflight "echo preflight" --git-checkpoint')
|
|
196
|
+
const out = await f.wake()
|
|
197
|
+
assert.equal(out.dryRun, true)
|
|
198
|
+
assert.equal(f.prompts.length + f.shells.length + f.hooks.length, 0)
|
|
199
|
+
assert.equal((await f.state()).jobs[0].runCount, 0)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
await test("compaction cadence does not charge a logical run", async (f) => {
|
|
203
|
+
await f.add("0s task --max-runs 2 --compact-every 1")
|
|
204
|
+
await f.wake()
|
|
205
|
+
await f.complete()
|
|
206
|
+
assert.equal(f.compacts.length, 1)
|
|
207
|
+
assert.equal((await f.state()).jobs[0].runCount, 1)
|
|
208
|
+
await f.event("compaction", "started")
|
|
209
|
+
await f.event("compaction", "ended")
|
|
210
|
+
await f.event("session", "idle")
|
|
211
|
+
assert.equal(f.prompts.length, 2)
|
|
212
|
+
assert.equal((await f.state()).jobs[0].runCount, 2)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
await test("a failed delivered execution is paused and can be explicitly resumed", async (f) => {
|
|
216
|
+
await f.add("0s task --max-runs 3")
|
|
217
|
+
await f.wake()
|
|
218
|
+
await f.event("inbox", "delivered", { inboxID: f.prompts[0].id })
|
|
219
|
+
await f.event("session", "error", { reason: "execution-failed" })
|
|
220
|
+
assert.equal((await f.state()).jobs[0].paused, true)
|
|
221
|
+
assert.equal((await f.state()).jobs[0].v2Run, undefined)
|
|
222
|
+
await f.control("loop-resume")
|
|
223
|
+
await f.wake()
|
|
224
|
+
assert.equal(f.prompts.length, 2)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
await test("branch failure cannot run on the wrong branch", async (f) => {
|
|
228
|
+
await f.add("0s task --branch native-loop-test --max-runs 1")
|
|
229
|
+
await f.wake()
|
|
230
|
+
assert.equal(f.prompts.length, 0)
|
|
231
|
+
assert.equal((await f.state()).jobs[0].pauseReason, "branch-unavailable")
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
{
|
|
235
|
+
let resolveBuild, started
|
|
236
|
+
const building = new Promise((resolve) => { started = resolve })
|
|
237
|
+
const gate = new Promise((resolve) => { resolveBuild = resolve })
|
|
238
|
+
await test("foreground admission during file preparation prevents dispatch", async (f) => {
|
|
239
|
+
await f.add("0s task --max-runs 1")
|
|
240
|
+
const pending = f.wake()
|
|
241
|
+
await building
|
|
242
|
+
await f.event("foreground", "admitted")
|
|
243
|
+
resolveBuild()
|
|
244
|
+
await pending
|
|
245
|
+
assert.equal(f.prompts.length, 0)
|
|
246
|
+
assert.equal((await f.state()).jobs[0].runCount, 0)
|
|
247
|
+
}, { workspace: { untilReached: async () => false, buildPrompt: async () => { started(); await gate; return "prepared" }, createCheckpoint: async () => {} } })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
{
|
|
251
|
+
const raw = { id: "event-native-duplicate", type: "session.inbox.delivered", location: { directory: "/workspace" }, data: { sessionID: "ses-native", inboxID: "msg-native" } }
|
|
252
|
+
assert.equal(normalizeOpenCode2NativeEvent(raw).inboxID, "msg-native")
|
|
253
|
+
let count = 0
|
|
254
|
+
const bridge = createOpenCode2EventBridge({ directory: "/workspace", onEvent: async () => { count++ } })
|
|
255
|
+
await bridge.dispatch(raw)
|
|
256
|
+
await bridge.dispatch(raw)
|
|
257
|
+
assert.equal(count, 1)
|
|
258
|
+
await bridge.dispose()
|
|
259
|
+
cases++
|
|
260
|
+
console.log(`ok ${cases}: duplicate native event IDs are processed once`)
|
|
261
|
+
}
|
|
262
|
+
console.log(`Native OpenCode 2 safety: ${cases} scenarios passed`)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
function versionFromOutput(output) {
|
|
5
|
+
for (const line of String(output || "").split(/\r?\n/)) {
|
|
6
|
+
const match = line.trim().match(/^(?:opencode(?:2)?\s+v?)?v?(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/)
|
|
7
|
+
if (match) return { major: Number(match[1]), version: line.trim() }
|
|
8
|
+
}
|
|
9
|
+
return undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Probe once before any workload. An explicit binary never falls back to a
|
|
13
|
+
// different installation, and native mode never silently selects a V1 host.
|
|
14
|
+
export async function selectOpenCodeHost({ binary, legacyV1 = false, probe } = {}) {
|
|
15
|
+
if (typeof probe !== "function") throw new TypeError("OpenCode host selection requires a version probe")
|
|
16
|
+
const major = legacyV1 ? 1 : 2
|
|
17
|
+
const explicit = String(binary || "").trim()
|
|
18
|
+
const candidates = explicit ? [explicit] : legacyV1 ? ["opencode"] : ["opencode", "opencode2"]
|
|
19
|
+
for (const candidate of candidates) {
|
|
20
|
+
const result = await probe(candidate)
|
|
21
|
+
if (result?.code !== 0 || result.timedOut) continue
|
|
22
|
+
const observed = versionFromOutput(`${result.stdout || ""}\n${result.stderr || ""}`)
|
|
23
|
+
if (observed?.major === major) return { binary: candidate, ...observed }
|
|
24
|
+
}
|
|
25
|
+
throw new Error(`Could not select OpenCode ${major}.x${explicit ? ` from ${JSON.stringify(explicit)}` : ""}. Set OPENCODE_BIN to the matching executable.${legacyV1 ? "" : " V1 requires explicit --legacy-v1; no workload was dispatched."}`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function canonicalDirectory(directory) {
|
|
29
|
+
const absolute = path.resolve(directory)
|
|
30
|
+
try { return fs.realpathSync.native(absolute) } catch { return absolute }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function projectSessions(text, directory, native = true) {
|
|
34
|
+
let parsed
|
|
35
|
+
try { parsed = JSON.parse(String(text || "")) } catch { throw new Error("OpenCode session list returned invalid JSON") }
|
|
36
|
+
const sessions = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.data) ? parsed.data : undefined
|
|
37
|
+
if (!sessions) throw new Error("OpenCode session list did not return an array")
|
|
38
|
+
const project = canonicalDirectory(directory)
|
|
39
|
+
return sessions.filter((session) => {
|
|
40
|
+
if (typeof session?.id !== "string" || !session.id.trim()) return false
|
|
41
|
+
const location = session.directory ?? session.location?.directory
|
|
42
|
+
// V2 session list is project-scoped, not worktree-scoped. Never let its
|
|
43
|
+
// newest session pull a daemon into another worktree or an unknown path.
|
|
44
|
+
if (typeof location !== "string" || !location.trim()) return !native
|
|
45
|
+
return canonicalDirectory(location) === project
|
|
46
|
+
})
|
|
47
|
+
}
|
|
@@ -11,7 +11,8 @@ import { fileURLToPath, pathToFileURL } from "node:url"
|
|
|
11
11
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
12
12
|
const LOOP_OBJECTIVE = "real OpenCode 2 loop canary"
|
|
13
13
|
const EXPECTED_TURNS = 2
|
|
14
|
-
const
|
|
14
|
+
const NATIVE_PRESETS = ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev", "loop-command", "loop-cmd", "loop-prompt", "loop-ask", "loop-shell"]
|
|
15
|
+
const EXPECTED_COMMANDS = ["loop", "loop-pause", "loop-resume", "loop-stop", "loop-remove", "loop-clear", ...(process.env.OPENCODE2_NATIVE_REQUIRED === "1" ? [...NATIVE_PRESETS, "loop-init"] : [])]
|
|
15
16
|
const SERVER_USERNAME = "opencode"
|
|
16
17
|
const SERVER_PASSWORD = "opencode-loop-v2-canary"
|
|
17
18
|
const OPENCODE_BINARY = process.env.OPENCODE2_BINARY || "opencode2"
|
|
@@ -181,7 +182,7 @@ export default {
|
|
|
181
182
|
return {
|
|
182
183
|
async *[Symbol.asyncIterator]() {
|
|
183
184
|
for await (const event of source) {
|
|
184
|
-
const line = JSON.stringify(event) + "\\n"
|
|
185
|
+
const line = JSON.stringify(String(event?.type || "").startsWith("session.") ? event : { id: event?.id, type: event?.type }) + "\\n"
|
|
185
186
|
if (traceFile) await appendFile(traceFile, line, "utf8")
|
|
186
187
|
if (pluginTraceFile) await appendFile(pluginTraceFile, line, "utf8")
|
|
187
188
|
yield event
|
|
@@ -192,10 +193,13 @@ export default {
|
|
|
192
193
|
},
|
|
193
194
|
}
|
|
194
195
|
: ctx
|
|
196
|
+
const goalURL = process.env.OPENCODE_GOAL_V2_PLUGIN_URL
|
|
197
|
+
const goalCleanup = goalURL ? await (await import(goalURL)).default.setup(ctx) : undefined
|
|
195
198
|
const cleanup = await module.default.setup(pluginContext)
|
|
196
199
|
await writeFile(process.env.OPENCODE_LOOP_V2_MARKER, JSON.stringify({ activated: true }, null, 2), "utf8")
|
|
197
200
|
return async () => {
|
|
198
201
|
if (typeof cleanup === "function") await cleanup()
|
|
202
|
+
if (typeof goalCleanup === "function") await goalCleanup()
|
|
199
203
|
}
|
|
200
204
|
},
|
|
201
205
|
}
|
|
@@ -252,6 +256,7 @@ async function main() {
|
|
|
252
256
|
},
|
|
253
257
|
}, null, 2)}\n`)
|
|
254
258
|
|
|
259
|
+
await writeFile(path.join(workspace, ".gitignore"), ".home/\n.opencode/\n*v2*events.jsonl\n")
|
|
255
260
|
execFileSync("git", ["init", "--quiet", workspace], { stdio: "ignore" })
|
|
256
261
|
execFileSync("git", ["-C", workspace, "config", "user.email", "opencode-loop-ci@example.invalid"], { stdio: "ignore" })
|
|
257
262
|
execFileSync("git", ["-C", workspace, "config", "user.name", "OpenCode Loop CI"], { stdio: "ignore" })
|
|
@@ -266,7 +271,7 @@ async function main() {
|
|
|
266
271
|
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
|
267
272
|
XDG_STATE_HOME: path.join(home, ".local", "state"),
|
|
268
273
|
XDG_CACHE_HOME: path.join(home, ".cache"),
|
|
269
|
-
OPENCODE_LOOP_V2_PLUGIN_URL: pathToFileURL(path.join(repoRoot, "src", "source", "opencode2", "experimental.js")).href,
|
|
274
|
+
OPENCODE_LOOP_V2_PLUGIN_URL: pathToFileURL(path.join(repoRoot, "src", ...(process.env.OPENCODE2_NATIVE_REQUIRED === "1" ? ["native.js"] : ["source", "opencode2", "experimental.js"]))).href,
|
|
270
275
|
OPENCODE_LOOP_V2_MARKER: marker,
|
|
271
276
|
OPENCODE_LOOP_V2_EVENT_TRACE: eventTrace,
|
|
272
277
|
OPENCODE_LOOP_V2_PLUGIN_EVENT_TRACE: pluginEventTrace,
|
|
@@ -364,6 +369,8 @@ async function main() {
|
|
|
364
369
|
return EXPECTED_COMMANDS.every((name) => latestCommands.has(name))
|
|
365
370
|
}, "plugin-registered Loop commands to enter the real V2 registry", diagnostics, 30_000)
|
|
366
371
|
|
|
372
|
+
if (process.env.OPENCODE_GOAL_V2_PLUGIN_URL) assert.ok(latestCommands.has("goal"), "both native plugin command registries must load")
|
|
373
|
+
|
|
367
374
|
const createdResponse = await request(`${apiPrefix}/session`, {
|
|
368
375
|
method: "POST",
|
|
369
376
|
body: JSON.stringify({ title: "OpenCode 2 Loop canary" }),
|
|
@@ -424,7 +431,7 @@ async function main() {
|
|
|
424
431
|
await sendControl("loop-resume", "default")
|
|
425
432
|
await waitFor(async () => {
|
|
426
433
|
const job = await jobNamed("default")
|
|
427
|
-
return job?.paused === false && job?.lastRunAt === 0
|
|
434
|
+
return job?.paused === false && (job?.lastRunAt === 0 || job?.runNowRequestedAt > 0)
|
|
428
435
|
}, "real V2 loop-resume state mutation", diagnostics, 30_000)
|
|
429
436
|
|
|
430
437
|
await sendControl("loop-stop", "default")
|
|
@@ -450,6 +457,67 @@ async function main() {
|
|
|
450
457
|
assert.equal(provider.stats.loopRequests, EXPECTED_TURNS + 2, `control lifecycle should add exactly two autonomous Loop turns\n${await diagnostics()}`)
|
|
451
458
|
assert.equal(server.exitCode, null, `OpenCode 2 server exited during control lifecycle canary\n${await diagnostics()}`)
|
|
452
459
|
|
|
460
|
+
if (process.env.OPENCODE2_NATIVE_REQUIRED === "1") {
|
|
461
|
+
const nativeLog = await readFile(path.join(workspace, ".opencode/opencode-loop/loop.log"), "utf8")
|
|
462
|
+
assert.match(nativeLog, /v2-native-ready/, "the real host must load the native implementation")
|
|
463
|
+
assert.match(nativeLog, /v2-native-admitted/, "the native implementation must own the admissions")
|
|
464
|
+
await writeFile(path.join(workspace, "native-preflight.cjs"), 'require("node:fs").appendFileSync("native-preflight.txt", "P")')
|
|
465
|
+
await writeFile(path.join(workspace, "native-verify.cjs"), 'require("node:fs").appendFileSync("native-verify.txt", "V")')
|
|
466
|
+
await sendControl("loop", '0s native verified task --max-runs 1 --name verified --preflight "node native-preflight.cjs" --verify "node native-verify.cjs"')
|
|
467
|
+
await waitFor(async () => {
|
|
468
|
+
const job = await jobNamed("verified")
|
|
469
|
+
return job?.runCount === 1 && !job.v2Run && job.lastVerifyCode === 0
|
|
470
|
+
}, "native preflight and post-turn verification", diagnostics, 30_000)
|
|
471
|
+
assert.equal(await readFile(path.join(workspace, "native-preflight.txt"), "utf8"), "P")
|
|
472
|
+
assert.equal(await readFile(path.join(workspace, "native-verify.txt"), "utf8"), "V")
|
|
473
|
+
await sendControl("loop-clear")
|
|
474
|
+
await sendControl("loop-shell", '0s node native-preflight.cjs --max-runs 1 --name native-shell')
|
|
475
|
+
await waitFor(async () => {
|
|
476
|
+
const job = await jobNamed("native-shell")
|
|
477
|
+
return job?.runCount === 1 && !job.v2Run
|
|
478
|
+
}, "native shell terminal correlation", diagnostics, 20_000)
|
|
479
|
+
assert.equal(await readFile(path.join(workspace, "native-preflight.txt"), "utf8"), "PP")
|
|
480
|
+
await sendControl("loop-clear")
|
|
481
|
+
const beforePresets = provider.stats.loopRequests
|
|
482
|
+
await sendControl("loop-init")
|
|
483
|
+
assert.match(await readFile(path.join(workspace, "progress.md"), "utf8"), /^# Progress\n/)
|
|
484
|
+
await writeFile(path.join(workspace, "progress.md"), "user-owned progress\n")
|
|
485
|
+
await sendControl("loop-init")
|
|
486
|
+
assert.equal(await readFile(path.join(workspace, "progress.md"), "utf8"), "user-owned progress\n")
|
|
487
|
+
for (const name of NATIVE_PRESETS.filter(name => name !== "loop-compact")) {
|
|
488
|
+
const action = ["loop-command", "loop-cmd"].includes(name) ? "/review project"
|
|
489
|
+
: name === "loop-shell" ? "npm test"
|
|
490
|
+
: ["loop-prompt", "loop-ask"].includes(name) ? "did you run tests?" : ""
|
|
491
|
+
await sendControl(name, `every 1h --dry-run --name ${name} ${action}`.trim())
|
|
492
|
+
const saved = await jobNamed(name)
|
|
493
|
+
assert.ok(saved, `${name} must create a native job`)
|
|
494
|
+
assert.equal(saved.intervalMs, 3600000, `${name}: explicit every 1h must survive native registration` )
|
|
495
|
+
assert.equal(saved.immediate, false)
|
|
496
|
+
assert.equal(saved.runCount, 0)
|
|
497
|
+
assert.equal(saved.dryRun, true)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
await sendControl("loop", "after 1h --now --max-runs 9 --dry-run --name native-once continue once")
|
|
501
|
+
const onceJob = await jobNamed("native-once")
|
|
502
|
+
assert.equal(onceJob.intervalMs, 3600000)
|
|
503
|
+
assert.equal(onceJob.maxRuns, 1, "after remains one-shot even with a conflicting max-runs flag")
|
|
504
|
+
assert.equal(onceJob.immediate, false, "after never dispatches immediately")
|
|
505
|
+
assert.equal(onceJob.action, "continue once")
|
|
506
|
+
await sendControl("loop-testfix", "every 1h --dry-run --name custom-testfix npm run check")
|
|
507
|
+
const testfix = await jobNamed("custom-testfix")
|
|
508
|
+
assert.equal(testfix.verifyCommand, "npm run check")
|
|
509
|
+
assert.match(testfix.action, /Test command hint: npm run check$/)
|
|
510
|
+
await sendControl("loop", "continue the project --dry-run --name native-shorthand")
|
|
511
|
+
const shorthand = await jobNamed("native-shorthand")
|
|
512
|
+
assert.equal(shorthand.intervalMs, 0)
|
|
513
|
+
assert.equal(shorthand.action, "continue the project")
|
|
514
|
+
assert.equal(shorthand.pauseReason, "dry-run")
|
|
515
|
+
|
|
516
|
+
assert.equal(provider.stats.loopRequests, beforePresets, "deferred presets and progress initialization cannot trigger model work")
|
|
517
|
+
await sendControl("loop-clear")
|
|
518
|
+
|
|
519
|
+
}
|
|
520
|
+
|
|
453
521
|
console.log(JSON.stringify({
|
|
454
522
|
ok: true,
|
|
455
523
|
apiPrefix,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { spawnSync } from "node:child_process"
|
|
3
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
|
|
8
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
9
|
+
const npmCLI = process.env.npm_execpath
|
|
10
|
+
assert.ok(npmCLI, "run this through npm run package:smoke")
|
|
11
|
+
function run(command, args, cwd) {
|
|
12
|
+
const result = spawnSync(command, args, { cwd, encoding: "utf8", timeout: 180_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true })
|
|
13
|
+
if (result.error) throw result.error
|
|
14
|
+
assert.equal(result.status, 0, [result.stdout, result.stderr].filter(Boolean).join("\n"))
|
|
15
|
+
return result.stdout
|
|
16
|
+
}
|
|
17
|
+
const temp = await mkdtemp(path.join(tmpdir(), "loop native package "))
|
|
18
|
+
try {
|
|
19
|
+
const packed = JSON.parse(run(process.execPath, [npmCLI, "pack", root, "--json", "--ignore-scripts"], temp))
|
|
20
|
+
assert.equal(packed.length, 1)
|
|
21
|
+
const files = new Set(packed[0].files.map((entry) => entry.path))
|
|
22
|
+
for (const entry of ["src/server.js", "src/native.js", "src/v1.js", "src/index.js"]) assert.ok(files.has(entry), `missing ${entry}`)
|
|
23
|
+
const consumer = path.join(temp, "consumer with spaces")
|
|
24
|
+
await mkdir(consumer)
|
|
25
|
+
await writeFile(path.join(consumer, "package.json"), JSON.stringify({ private: true, type: "module" }))
|
|
26
|
+
run(process.execPath, [npmCLI, "install", "--ignore-scripts", "--omit=dev", "--omit=peer", "--no-audit", "--no-fund", path.join(temp, packed[0].filename)], consumer)
|
|
27
|
+
console.log(run(process.execPath, [path.join(root, "scripts/native-entry-test.mjs"), consumer], root).trim())
|
|
28
|
+
console.log("Production tarball native entry isolation and lazy legacy delegation PASS")
|
|
29
|
+
} finally {
|
|
30
|
+
await rm(temp, { recursive: true, force: true, maxRetries: 8, retryDelay: 100 })
|
|
31
|
+
}
|
|
@@ -2,29 +2,70 @@ import assert from "node:assert/strict"
|
|
|
2
2
|
import { readFile } from "node:fs/promises"
|
|
3
3
|
import path from "node:path"
|
|
4
4
|
import { fileURLToPath } from "node:url"
|
|
5
|
+
import { validateRelease, releaseDecision, assertPublishedSource, waitForPublishedGoal } from "./native-release.mjs"
|
|
5
6
|
|
|
6
7
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
assert.match(workflow, /
|
|
11
|
-
assert.
|
|
12
|
-
assert.match(workflow, /
|
|
13
|
-
assert.match(workflow, /
|
|
14
|
-
assert.match(workflow, /
|
|
15
|
-
assert.match(workflow, /
|
|
16
|
-
|
|
17
|
-
assert.match(workflow, /
|
|
18
|
-
assert.match(workflow, /
|
|
19
|
-
assert.match(workflow, /
|
|
20
|
-
assert.match(workflow,
|
|
8
|
+
const workflow = await readFile(path.join(repoRoot, ".github/workflows/publish-npm.yml"), "utf8")
|
|
9
|
+
assert.match(workflow, /ref:\s*\$\{\{ github\.sha \}\}/, "checkout must use the immutable trigger SHA")
|
|
10
|
+
assert.doesNotMatch(workflow, /ref:\s*main\b/, "publication cannot checkout moving main")
|
|
11
|
+
assert.match(workflow, /git merge-base --is-ancestor "\$GITHUB_SHA" origin\/main/)
|
|
12
|
+
assert.match(workflow, /COMMIT_SHA="\$GITHUB_SHA"/)
|
|
13
|
+
assert.match(workflow, /group:\s*npm-release/)
|
|
14
|
+
assert.match(workflow, /cancel-in-progress:\s*false/)
|
|
15
|
+
assert.match(workflow, /branches:\s*\[main\]/)
|
|
16
|
+
assert.match(workflow, /paths:\s*\[\.github\/npm-release\.json\]/)
|
|
17
|
+
assert.doesNotMatch(workflow, /release\/npm-v|git push|Remove one-shot release branch/, "main-only publication must not create or delete branches")
|
|
18
|
+
assert.match(workflow, /needs:\s*\[native-regressions, real-native-pair\]/)
|
|
19
|
+
assert.match(workflow, /node scripts\/native-release\.mjs ci/)
|
|
20
|
+
assert.match(workflow, /git diff --exit-code -- src\/index.js src\/server.js src\/native.js/)
|
|
21
|
+
assert.match(workflow, /npm run package:smoke/)
|
|
22
|
+
assert.match(workflow, /windows-latest/)
|
|
23
|
+
assert.match(workflow, /Decide whether npm publish is needed/)
|
|
24
|
+
assert.match(workflow, /steps\.npm-state\.outputs\.publish == 'true'/)
|
|
25
|
+
assert.match(workflow, /for attempt in \{1\.\.60\}/)
|
|
26
|
+
assert.match(workflow, /--registry=https:\/\/registry\.npmjs\.org/)
|
|
27
|
+
assert.match(workflow, /native-release\.mjs verify/)
|
|
28
|
+
assert.match(workflow, /node_modules\/@bybrawe\/opencode-loop\/scripts\/opencode2-loop-canary.mjs/)
|
|
29
|
+
assert.match(workflow, /node_modules\/@bybrawe\/opencode-goal\/dist\/server.js/)
|
|
30
|
+
assert.match(workflow, /ref: \$\{\{ steps.release.outputs.goal_sha \}\}/)
|
|
31
|
+
assert.doesNotMatch(workflow, /secrets\.NPM_TOKEN/)
|
|
21
32
|
|
|
22
33
|
const publishIndex = workflow.indexOf("npm publish --access public")
|
|
23
34
|
const verifyIndex = workflow.indexOf("Verify published version is readable from npm")
|
|
24
35
|
const tagIndex = workflow.indexOf("Create tag and GitHub release")
|
|
25
|
-
|
|
26
|
-
assert.ok(
|
|
27
|
-
assert.
|
|
28
|
-
|
|
36
|
+
assert.ok(publishIndex >= 0 && verifyIndex > publishIndex)
|
|
37
|
+
assert.ok(tagIndex > verifyIndex)
|
|
38
|
+
assert.match(workflow.slice(tagIndex), /needs:\s*publish/, "GitHub release must depend on verified publication")
|
|
39
|
+
for (const block of workflow.split(/- uses: actions\/checkout@/).slice(1)) assert.match(block.split(/\n\s*- (?:uses|name|run):/)[0], /persist-credentials:\s*false/)
|
|
40
|
+
const publisher = workflow.slice(workflow.indexOf(" publish:\n"), workflow.indexOf(" github-release:\n"))
|
|
41
|
+
assert.doesNotMatch(publisher, /contents:\s*write/, "OIDC publisher must not gain repository mutation privileges")
|
|
42
|
+
|
|
43
|
+
const sha = "a".repeat(40)
|
|
44
|
+
const request = { name: "@bybrawe/opencode-loop", version: "0.6.0", predecessor: "0.5.38", goal: { version: "1.3.39", sha: "b".repeat(40) } }
|
|
45
|
+
const pkg = { name: request.name, version: request.version }
|
|
46
|
+
const env = { GITHUB_REPOSITORY: "ByBrawe/opencode-loop", GITHUB_REF: "refs/heads/main", GITHUB_SHA: sha }
|
|
47
|
+
assert.strictEqual(validateRelease(request, pkg, env), request)
|
|
48
|
+
for (const change of [{ GITHUB_REPOSITORY: "someone/fork" }, { GITHUB_REF: "refs/heads/release" }, { GITHUB_SHA: "main" }]) assert.throws(() => validateRelease(request, pkg, { ...env, ...change }))
|
|
49
|
+
assert.throws(() => validateRelease({ ...request, version: "9.0.0" }, pkg, env))
|
|
50
|
+
assert.throws(() => validateRelease(request, { ...pkg, version: "0.5.38" }, env))
|
|
51
|
+
assert.equal(releaseDecision(404, null, request, sha, "0.5.38"), true)
|
|
52
|
+
for (const status of [401, 403, 429, 500, 503]) assert.throws(() => releaseDecision(status, null, request, sha, "0.5.38"))
|
|
53
|
+
assert.throws(() => releaseDecision(404, null, request, sha, "0.7.0"))
|
|
54
|
+
const manifest = { ...pkg, gitHead: sha }
|
|
55
|
+
assert.equal(releaseDecision(200, manifest, request, sha), false)
|
|
56
|
+
assert.throws(() => releaseDecision(200, { ...manifest, gitHead: "b".repeat(40) }, request, sha))
|
|
57
|
+
assert.throws(() => assertPublishedSource({ ...manifest, gitHead: undefined }, pkg.name, pkg.version, sha))
|
|
29
58
|
|
|
30
|
-
|
|
59
|
+
let calls = 0, sleeps = 0
|
|
60
|
+
const goalManifest = { name: "@bybrawe/opencode-goal", version: request.goal.version, gitHead: request.goal.sha }
|
|
61
|
+
const recovered = await waitForPublishedGoal(request.goal, {
|
|
62
|
+
attempts: 2, sleep: async () => { sleeps++ },
|
|
63
|
+
request: async () => ++calls === 1 ? { ok: false, status: 404 } : { ok: true, json: async () => goalManifest },
|
|
64
|
+
})
|
|
65
|
+
assert.deepEqual(recovered, goalManifest)
|
|
66
|
+
assert.equal(calls, 2)
|
|
67
|
+
assert.equal(sleeps, 1)
|
|
68
|
+
await assert.rejects(waitForPublishedGoal(request.goal, { attempts: 1, request: async () => ({ ok: false, status: 404 }) }), /not published/)
|
|
69
|
+
for (const status of [403, 429, 503]) await assert.rejects(waitForPublishedGoal(request.goal, { attempts: 1, request: async () => ({ ok: false, status }) }), /transport\/auth/)
|
|
70
|
+
await assert.rejects(waitForPublishedGoal(request.goal, { attempts: 1, request: async () => ({ ok: true, json: async () => ({ ...goalManifest, gitHead: sha }) }) }), /different source/)
|
|
71
|
+
console.log("main-only publish workflow, source identity and registry failure regressions passed")
|