@bybrawe/opencode-loop 0.5.37 → 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 +26 -0
- package/README.md +31 -1
- package/examples/OPENCODE2-0.6.0.md +54 -0
- package/package.json +12 -8
- package/scripts/branch-hygiene.mjs +326 -0
- package/scripts/comprehensive-test.mjs +19 -10
- package/scripts/control-plane-until-test.mjs +39 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
- 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 -58
- package/scripts/install-test.mjs +6 -1
- 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/network-recovery-test.mjs +38 -1
- package/scripts/opencode-host.mjs +47 -0
- package/scripts/opencode2-loop-canary.mjs +119 -10
- package/scripts/package-native-smoke.mjs +31 -0
- package/scripts/publish-workflow-test.mjs +60 -14
- package/scripts/pulse-check.mjs +246 -0
- package/scripts/run-finalization-test.mjs +33 -0
- package/scripts/server-entry-test.mjs +15 -0
- package/scripts/session-error-recovery-test.mjs +186 -0
- package/scripts/session-status-test.mjs +42 -0
- package/scripts/v2-command-adapter-test.mjs +14 -2
- package/scripts/v2-plugin-sentinel-test.mjs +35 -4
- package/scripts/v2-runtime-adapter-test.mjs +75 -0
- package/src/index.js +227 -53
- package/src/native.js +3135 -0
- package/src/server.js +4055 -0
- package/src/source/core/continuation.js +23 -0
- 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 +14 -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/capabilities.js +1 -0
- package/src/source/opencode2/commands.js +52 -11
- package/src/source/opencode2/event-bridge.js +15 -3
- package/src/source/opencode2/events.js +31 -0
- package/src/source/opencode2/experimental.js +53 -7
- 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 +38 -6
- package/src/source/opencode2/runtime-adapter.js +15 -2
- package/src/source/opencode2/session-location.js +16 -0
- package/src/source/runtime/executor.js +83 -0
- package/src/source/runtime/goal-steering.js +17 -3
- package/src/source/runtime/job-workspace.js +11 -2
- package/src/source/runtime/run-finalization.js +10 -0
- package/src/source/runtime/session-status.js +16 -2
- package/src/source/runtime/terminal-guard.js +32 -7
- package/src/source/server.js +26 -0
- 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`)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict"
|
|
2
|
-
import { isCompletionBoundedContinuation, isTerminalNoWorkReply } from "../src/source/core/continuation.js"
|
|
2
|
+
import { isCompletionBoundedContinuation, isTerminalNoWorkReply, isWaitingUserReply } from "../src/source/core/continuation.js"
|
|
3
3
|
import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "../src/source/runtime/network-recovery.js"
|
|
4
4
|
import { createSessionStatusRuntime } from "../src/source/runtime/session-status.js"
|
|
5
5
|
import { applyTerminalContinuationGuard } from "../src/source/runtime/terminal-guard.js"
|
|
@@ -90,6 +90,10 @@ assert.equal(isCompletionBoundedContinuation("devam et"), false)
|
|
|
90
90
|
assert.equal(isCompletionBoundedContinuation("devam et bitene kadar devam tamamen projeyi bitir"), true)
|
|
91
91
|
assert.equal(isTerminalNoWorkReply("Proje tamamlandı — 28/28 test yeşil. Yapılacak iş yok. Sıfır bilinen hata."), true)
|
|
92
92
|
assert.equal(isTerminalNoWorkReply("Proje tamamlandı ama sıradaki iş build doğrulaması."), false)
|
|
93
|
+
assert.equal(isWaitingUserReply("Durum sabit.\n\nOnay bekleyenler: commit + push, canlı migration, cihaz testi."), true)
|
|
94
|
+
assert.equal(isWaitingUserReply("Waiting for your approval before I can push the release."), true)
|
|
95
|
+
assert.equal(isWaitingUserReply("Onay gerekmiyor; sıradaki iş build doğrulaması."), false)
|
|
96
|
+
assert.equal(isWaitingUserReply("Deployment needs your approval, but next step is local verification."), false)
|
|
93
97
|
|
|
94
98
|
{
|
|
95
99
|
const terminalMessages = [{
|
|
@@ -122,6 +126,39 @@ assert.equal(isTerminalNoWorkReply("Proje tamamlandı ama sıradaki iş build do
|
|
|
122
126
|
const untouched = await applyTerminalContinuationGuard("/repo", client, "s", infinite)
|
|
123
127
|
assert.equal(untouched.terminal, false)
|
|
124
128
|
assert.equal(untouched.job.paused, false, "plain /loop devam et must remain intentionally infinite")
|
|
129
|
+
|
|
130
|
+
const waitingMessages = [{
|
|
131
|
+
info: { role: "assistant", time: { created: 2_200, completed: 2_300 } },
|
|
132
|
+
parts: [{ type: "text", text: "Durum sabit.\n\nOnay bekleyenler: commit + push, canlı migration, cihaz testi." }],
|
|
133
|
+
}]
|
|
134
|
+
const waitingClient = { session: { messages: async () => ({ data: waitingMessages }) } }
|
|
135
|
+
const waiting = {
|
|
136
|
+
id: "waiting",
|
|
137
|
+
action: "devam et",
|
|
138
|
+
scheduleMode: "idle",
|
|
139
|
+
lastRunAt: 1_500,
|
|
140
|
+
paused: false,
|
|
141
|
+
}
|
|
142
|
+
let waitingResult = await applyTerminalContinuationGuard("/repo", waitingClient, "s", waiting)
|
|
143
|
+
assert.equal(waitingResult.waitingUser, true)
|
|
144
|
+
assert.equal(waitingResult.waitingUserPausedNow, false)
|
|
145
|
+
assert.equal(waitingResult.job.waitingUserCount, 1)
|
|
146
|
+
assert.equal(waitingResult.job.paused, false)
|
|
147
|
+
waitingResult = await applyTerminalContinuationGuard("/repo", waitingClient, "s", waitingResult.job)
|
|
148
|
+
assert.equal(waitingResult.waitingUserPausedNow, true, "two explicit user-dependency replies should pause plain continuation")
|
|
149
|
+
assert.equal(waitingResult.job.paused, true)
|
|
150
|
+
assert.equal(waitingResult.job.lastFailureReason, "waiting_user")
|
|
151
|
+
|
|
152
|
+
const productiveMessages = [{
|
|
153
|
+
info: { role: "assistant", time: { created: 2_400, completed: 2_500 } },
|
|
154
|
+
parts: [{ type: "text", text: "Deployment needs your approval, but next step is local verification." }],
|
|
155
|
+
}]
|
|
156
|
+
const productiveClient = { session: { messages: async () => ({ data: productiveMessages }) } }
|
|
157
|
+
const productive = { ...waitingResult.job, paused: false, waitingUserCount: 1 }
|
|
158
|
+
const productiveResult = await applyTerminalContinuationGuard("/repo", productiveClient, "s", productive)
|
|
159
|
+
assert.equal(productiveResult.waitingUser, false)
|
|
160
|
+
assert.equal(productiveResult.job.waitingUserCount, 0, "a reply with autonomous next work must clear waiting-user streak")
|
|
161
|
+
assert.equal(productiveResult.job.paused, false)
|
|
125
162
|
}
|
|
126
163
|
|
|
127
164
|
{
|
|
@@ -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
|
+
}
|
|
@@ -2,7 +2,7 @@ import assert from "node:assert/strict"
|
|
|
2
2
|
import { createServer } from "node:http"
|
|
3
3
|
import net from "node:net"
|
|
4
4
|
import { execFileSync, spawn } from "node:child_process"
|
|
5
|
-
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
|
5
|
+
import { appendFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
|
6
6
|
import os from "node:os"
|
|
7
7
|
import path from "node:path"
|
|
8
8
|
import process from "node:process"
|
|
@@ -11,9 +11,12 @@ 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"
|
|
18
|
+
const OPENCODE_BINARY = process.env.OPENCODE2_BINARY || "opencode2"
|
|
19
|
+
const CURRENT_COMMAND_FIELDS = process.env.OPENCODE2_CURRENT_COMMAND_FIELDS === "1"
|
|
17
20
|
|
|
18
21
|
function appendLog(current, chunk, limit = 100_000) {
|
|
19
22
|
return (current + String(chunk)).slice(-limit)
|
|
@@ -161,16 +164,42 @@ function startProvider() {
|
|
|
161
164
|
}
|
|
162
165
|
|
|
163
166
|
function bridgePluginSource() {
|
|
164
|
-
return `import { writeFile } from "node:fs/promises"
|
|
167
|
+
return `import { appendFile, writeFile } from "node:fs/promises"
|
|
165
168
|
|
|
166
169
|
export default {
|
|
167
170
|
id: "bybrawe.opencode-loop.v2.loop-canary-bridge",
|
|
168
171
|
async setup(ctx) {
|
|
169
172
|
const module = await import(process.env.OPENCODE_LOOP_V2_PLUGIN_URL)
|
|
170
|
-
const
|
|
173
|
+
const traceFile = process.env.OPENCODE_LOOP_V2_EVENT_TRACE
|
|
174
|
+
const pluginTraceFile = process.env.OPENCODE_LOOP_V2_PLUGIN_EVENT_TRACE
|
|
175
|
+
const pluginContext = typeof ctx?.event?.subscribe === "function"
|
|
176
|
+
? {
|
|
177
|
+
...ctx,
|
|
178
|
+
event: {
|
|
179
|
+
...ctx.event,
|
|
180
|
+
subscribe(options) {
|
|
181
|
+
const source = ctx.event.subscribe(options)
|
|
182
|
+
return {
|
|
183
|
+
async *[Symbol.asyncIterator]() {
|
|
184
|
+
for await (const event of source) {
|
|
185
|
+
const line = JSON.stringify(String(event?.type || "").startsWith("session.") ? event : { id: event?.id, type: event?.type }) + "\\n"
|
|
186
|
+
if (traceFile) await appendFile(traceFile, line, "utf8")
|
|
187
|
+
if (pluginTraceFile) await appendFile(pluginTraceFile, line, "utf8")
|
|
188
|
+
yield event
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
},
|
|
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
|
|
198
|
+
const cleanup = await module.default.setup(pluginContext)
|
|
171
199
|
await writeFile(process.env.OPENCODE_LOOP_V2_MARKER, JSON.stringify({ activated: true }, null, 2), "utf8")
|
|
172
200
|
return async () => {
|
|
173
201
|
if (typeof cleanup === "function") await cleanup()
|
|
202
|
+
if (typeof goalCleanup === "function") await goalCleanup()
|
|
174
203
|
}
|
|
175
204
|
},
|
|
176
205
|
}
|
|
@@ -187,8 +216,10 @@ async function main() {
|
|
|
187
216
|
|
|
188
217
|
const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-e2e-"))
|
|
189
218
|
const home = path.join(workspace, ".home")
|
|
190
|
-
const pluginDir = path.join(
|
|
219
|
+
const pluginDir = path.join(home, ".config", "opencode", "plugins")
|
|
191
220
|
const marker = path.join(workspace, "v2-real-adapter-marker.json")
|
|
221
|
+
const eventTrace = path.join(workspace, "v2-native-events.jsonl")
|
|
222
|
+
const pluginEventTrace = path.join(workspace, "v2-plugin-events.jsonl")
|
|
192
223
|
const provider = startProvider()
|
|
193
224
|
const providerPort = await provider.listen()
|
|
194
225
|
let server
|
|
@@ -225,6 +256,7 @@ async function main() {
|
|
|
225
256
|
},
|
|
226
257
|
}, null, 2)}\n`)
|
|
227
258
|
|
|
259
|
+
await writeFile(path.join(workspace, ".gitignore"), ".home/\n.opencode/\n*v2*events.jsonl\n")
|
|
228
260
|
execFileSync("git", ["init", "--quiet", workspace], { stdio: "ignore" })
|
|
229
261
|
execFileSync("git", ["-C", workspace, "config", "user.email", "opencode-loop-ci@example.invalid"], { stdio: "ignore" })
|
|
230
262
|
execFileSync("git", ["-C", workspace, "config", "user.name", "OpenCode Loop CI"], { stdio: "ignore" })
|
|
@@ -239,8 +271,10 @@ async function main() {
|
|
|
239
271
|
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
|
240
272
|
XDG_STATE_HOME: path.join(home, ".local", "state"),
|
|
241
273
|
XDG_CACHE_HOME: path.join(home, ".cache"),
|
|
242
|
-
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,
|
|
243
275
|
OPENCODE_LOOP_V2_MARKER: marker,
|
|
276
|
+
OPENCODE_LOOP_V2_EVENT_TRACE: eventTrace,
|
|
277
|
+
OPENCODE_LOOP_V2_PLUGIN_EVENT_TRACE: pluginEventTrace,
|
|
244
278
|
OPENCODE_SERVER_USERNAME: SERVER_USERNAME,
|
|
245
279
|
OPENCODE_SERVER_PASSWORD: SERVER_PASSWORD,
|
|
246
280
|
OPENCODE_DISABLE_AUTOUPDATE: "true",
|
|
@@ -253,6 +287,10 @@ async function main() {
|
|
|
253
287
|
if (sessionID) {
|
|
254
288
|
try { state = await readFile(path.join(workspace, ".opencode", "opencode-loop", `${sessionID}.json`), "utf8") } catch {}
|
|
255
289
|
}
|
|
290
|
+
let nativeEvents = "missing"
|
|
291
|
+
let pluginEvents = "missing"
|
|
292
|
+
try { nativeEvents = await readFile(eventTrace, "utf8") } catch {}
|
|
293
|
+
try { pluginEvents = await readFile(pluginEventTrace, "utf8") } catch {}
|
|
256
294
|
return [
|
|
257
295
|
`apiPrefix=${String(apiPrefix)}`,
|
|
258
296
|
`commands=${JSON.stringify([...latestCommands])}`,
|
|
@@ -260,14 +298,18 @@ async function main() {
|
|
|
260
298
|
`sessionID=${sessionID || "none"}`,
|
|
261
299
|
`commandError=${String(commandError ?? "none")}`,
|
|
262
300
|
`state=${state}`,
|
|
301
|
+
`binary=${OPENCODE_BINARY}`,
|
|
302
|
+
`currentCommandFields=${CURRENT_COMMAND_FIELDS}`,
|
|
263
303
|
`serverExit=${server?.exitCode}`,
|
|
304
|
+
`nativeEvents=${nativeEvents.slice(-40_000)}`,
|
|
305
|
+
`pluginEvents=${pluginEvents.slice(-40_000)}`,
|
|
264
306
|
`serverLog=${serverLog}`,
|
|
265
307
|
].join("\n")
|
|
266
308
|
}
|
|
267
309
|
|
|
268
310
|
try {
|
|
269
311
|
const port = await reservePort()
|
|
270
|
-
server = spawn(
|
|
312
|
+
server = spawn(OPENCODE_BINARY, ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
|
|
271
313
|
cwd: workspace,
|
|
272
314
|
env,
|
|
273
315
|
windowsHide: true,
|
|
@@ -327,6 +369,8 @@ async function main() {
|
|
|
327
369
|
return EXPECTED_COMMANDS.every((name) => latestCommands.has(name))
|
|
328
370
|
}, "plugin-registered Loop commands to enter the real V2 registry", diagnostics, 30_000)
|
|
329
371
|
|
|
372
|
+
if (process.env.OPENCODE_GOAL_V2_PLUGIN_URL) assert.ok(latestCommands.has("goal"), "both native plugin command registries must load")
|
|
373
|
+
|
|
330
374
|
const createdResponse = await request(`${apiPrefix}/session`, {
|
|
331
375
|
method: "POST",
|
|
332
376
|
body: JSON.stringify({ title: "OpenCode 2 Loop canary" }),
|
|
@@ -336,9 +380,13 @@ async function main() {
|
|
|
336
380
|
sessionID = String(session?.id ?? "")
|
|
337
381
|
assert.ok(sessionID, `OpenCode 2 did not create a session: ${createdResponse.text}\n${await diagnostics()}`)
|
|
338
382
|
|
|
383
|
+
const commandBody = (command, argumentsText = "") => CURRENT_COMMAND_FIELDS
|
|
384
|
+
? { name: command, text: argumentsText }
|
|
385
|
+
: { command, arguments: argumentsText }
|
|
386
|
+
|
|
339
387
|
const commandPromise = request(`${apiPrefix}/session/${encodeURIComponent(sessionID)}/command`, {
|
|
340
388
|
method: "POST",
|
|
341
|
-
body: JSON.stringify(
|
|
389
|
+
body: JSON.stringify(commandBody("loop", `0s --max-runs ${EXPECTED_TURNS} ${LOOP_OBJECTIVE}`)),
|
|
342
390
|
}, 120_000).catch((error) => {
|
|
343
391
|
commandError = error
|
|
344
392
|
return null
|
|
@@ -371,7 +419,7 @@ async function main() {
|
|
|
371
419
|
const sendControl = async (command, argumentsText = "") => {
|
|
372
420
|
const response = await request(`${apiPrefix}/session/${encodeURIComponent(sessionID)}/command`, {
|
|
373
421
|
method: "POST",
|
|
374
|
-
body: JSON.stringify(
|
|
422
|
+
body: JSON.stringify(commandBody(command, argumentsText)),
|
|
375
423
|
}, 120_000)
|
|
376
424
|
if (!response.ok) throw new Error(`${command} failed: HTTP ${response.status} ${response.text}\n${await diagnostics()}`)
|
|
377
425
|
return response
|
|
@@ -383,7 +431,7 @@ async function main() {
|
|
|
383
431
|
await sendControl("loop-resume", "default")
|
|
384
432
|
await waitFor(async () => {
|
|
385
433
|
const job = await jobNamed("default")
|
|
386
|
-
return job?.paused === false && job?.lastRunAt === 0
|
|
434
|
+
return job?.paused === false && (job?.lastRunAt === 0 || job?.runNowRequestedAt > 0)
|
|
387
435
|
}, "real V2 loop-resume state mutation", diagnostics, 30_000)
|
|
388
436
|
|
|
389
437
|
await sendControl("loop-stop", "default")
|
|
@@ -409,6 +457,67 @@ async function main() {
|
|
|
409
457
|
assert.equal(provider.stats.loopRequests, EXPECTED_TURNS + 2, `control lifecycle should add exactly two autonomous Loop turns\n${await diagnostics()}`)
|
|
410
458
|
assert.equal(server.exitCode, null, `OpenCode 2 server exited during control lifecycle canary\n${await diagnostics()}`)
|
|
411
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
|
+
|
|
412
521
|
console.log(JSON.stringify({
|
|
413
522
|
ok: true,
|
|
414
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
|
+
}
|