@bybrawe/opencode-loop 0.6.6 → 0.6.8
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 +20 -0
- package/README.md +7 -3
- package/examples/OPENCODE2-0.6.7.md +26 -0
- package/examples/OPENCODE2-0.6.8.md +18 -0
- package/package.json +3 -3
- package/scripts/comprehensive-test.mjs +3 -3
- package/scripts/desktop-command-diagnostics-test.mjs +92 -0
- package/scripts/desktop-command-diagnostics.mjs +237 -0
- package/scripts/host-goal-bootstrap-test.mjs +46 -0
- package/scripts/host-goal-bootstrap.mjs +71 -0
- package/scripts/host-goal-coexistence-canary.mjs +11 -2
- package/scripts/install-with-goals.mjs +11 -0
- package/scripts/native-plugin-lifetime-test.mjs +39 -6
- package/scripts/native-release.mjs +3 -3
- package/scripts/native-v2-test.mjs +64 -2
- package/scripts/opencode2-loop-canary.mjs +55 -1
- package/scripts/package-native-smoke.mjs +40 -5
- package/scripts/publish-workflow-test.mjs +6 -2
- package/scripts/state-exfat-lock-test.mjs +99 -0
- package/scripts/state-integrity-test.mjs +47 -0
- package/scripts/state-process-test.mjs +172 -0
- package/src/index.js +249 -11
- package/src/native.js +268 -19
- package/src/server.js +268 -19
- package/src/source/core/state.js +236 -9
- package/src/source/opencode2/events.js +1 -1
- package/src/source/opencode2/native-plugin.js +4 -6
- package/src/source/opencode2/native-runtime.js +30 -4
|
@@ -7,11 +7,12 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promis
|
|
|
7
7
|
import os from "node:os"
|
|
8
8
|
import path from "node:path"
|
|
9
9
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
10
|
+
import { waitForSessionBootstrap } from "./host-goal-bootstrap.mjs"
|
|
10
11
|
|
|
11
12
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
12
13
|
const isWindows = process.platform === "win32"
|
|
13
14
|
const SERVER_READY_TIMEOUT_MS = isWindows ? 60_000 : 30_000
|
|
14
|
-
const API_BOOTSTRAP_TIMEOUT_MS = isWindows ?
|
|
15
|
+
const API_BOOTSTRAP_TIMEOUT_MS = isWindows ? 180_000 : 60_000
|
|
15
16
|
const PREWARM_TIMEOUT_MS = isWindows ? 120_000 : 60_000
|
|
16
17
|
const GOAL_OBJECTIVE = "prove dedicated Goal owns continuation over Loop"
|
|
17
18
|
const GOAL_PROMPT_MARKER = "Continue working toward the active OpenCode goal."
|
|
@@ -341,7 +342,15 @@ async function main() {
|
|
|
341
342
|
}
|
|
342
343
|
}
|
|
343
344
|
|
|
344
|
-
|
|
345
|
+
// A Windows cold host may accept TCP before its session/project/plugin
|
|
346
|
+
// services are ready. Probe readiness in bounded requests rather than
|
|
347
|
+
// hanging for 90 seconds on one socket and attributing it to Goal/Loop.
|
|
348
|
+
const sessionsPayload = await waitForSessionBootstrap({
|
|
349
|
+
baseURL,
|
|
350
|
+
workspace,
|
|
351
|
+
timeoutMs: API_BOOTSTRAP_TIMEOUT_MS,
|
|
352
|
+
fetchImpl: fetch,
|
|
353
|
+
})
|
|
345
354
|
assert.ok(Array.isArray(sessionsPayload?.data ?? sessionsPayload), "GET /session bootstrap did not return an array")
|
|
346
355
|
const createdPayload = await api("/session", { method: "POST", body: JSON.stringify({ title: "Loop + Goal ownership canary" }) })
|
|
347
356
|
const session = createdPayload?.data ?? createdPayload
|
|
@@ -83,6 +83,17 @@ function printCompanionHelp() {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
async function main() {
|
|
86
|
+
if (rawArgs[0] === "--desktop-diagnostics") {
|
|
87
|
+
// Diagnostic mode must not run either installer or modify any configuration.
|
|
88
|
+
const { runDesktopDiagnosticCli } = await import("./desktop-command-diagnostics.mjs")
|
|
89
|
+
process.exitCode = await runDesktopDiagnosticCli(rawArgs.slice(1))
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if (rawArgs.includes("--desktop-diagnostics")) {
|
|
93
|
+
console.error("--desktop-diagnostics must be the first argument.")
|
|
94
|
+
process.exitCode = 2
|
|
95
|
+
return
|
|
96
|
+
}
|
|
86
97
|
if (withGoals && loopOnly) {
|
|
87
98
|
console.error("Use either --with-goals or --loop-only, not both.")
|
|
88
99
|
process.exitCode = 2
|
|
@@ -5,8 +5,10 @@ import os from "node:os"
|
|
|
5
5
|
import path from "node:path"
|
|
6
6
|
import plugin from "../src/source/opencode2/native-plugin.js"
|
|
7
7
|
|
|
8
|
-
function fixture(directory, { failCleanup = false } = {}) {
|
|
8
|
+
function fixture(directory, { failCleanup = false, captureCommands = false } = {}) {
|
|
9
9
|
const released = []
|
|
10
|
+
const commands = new Map()
|
|
11
|
+
const prompts = []
|
|
10
12
|
let releaseRead, signal
|
|
11
13
|
let streamClosed = false
|
|
12
14
|
const end = () => releaseRead?.()
|
|
@@ -18,10 +20,17 @@ function fixture(directory, { failCleanup = false } = {}) {
|
|
|
18
20
|
location: { directory }, options: {}, app: { version: "2.0.18" },
|
|
19
21
|
session: {
|
|
20
22
|
get: async ({ sessionID }) => ({ id: sessionID, location: { directory } }),
|
|
21
|
-
prompt: async () => {
|
|
23
|
+
prompt: async (request) => {
|
|
24
|
+
if (!captureCommands) throw new Error("cleanup must not dispatch a prompt")
|
|
25
|
+
prompts.push(request)
|
|
26
|
+
return { id: "test-prompt" }
|
|
27
|
+
},
|
|
22
28
|
hook: async (name) => registration(name),
|
|
23
29
|
},
|
|
24
|
-
command: { transform: async () =>
|
|
30
|
+
command: { transform: async (register) => {
|
|
31
|
+
if (captureCommands) await register({ add: ({ name, execute }) => commands.set(name, execute) })
|
|
32
|
+
return registration("commands")
|
|
33
|
+
} },
|
|
25
34
|
event: { subscribe(options = {}) {
|
|
26
35
|
signal = options.signal
|
|
27
36
|
return (async function* () {
|
|
@@ -35,7 +44,7 @@ function fixture(directory, { failCleanup = false } = {}) {
|
|
|
35
44
|
})()
|
|
36
45
|
} },
|
|
37
46
|
}
|
|
38
|
-
return { ctx, end, released, signal: () => signal, streamClosed: () => streamClosed }
|
|
47
|
+
return { ctx, end, released, commands, prompts, signal: () => signal, streamClosed: () => streamClosed }
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
async function bounded(promise) {
|
|
@@ -100,19 +109,22 @@ test("native Loop cleanup is shared and drains all registrations after an error"
|
|
|
100
109
|
}
|
|
101
110
|
})
|
|
102
111
|
|
|
103
|
-
test("native Loop never probes undocumented SessionDomain shell
|
|
112
|
+
test("native Loop never probes undocumented SessionDomain shell or inbox fields", async () => {
|
|
104
113
|
const directory = await mkdtemp(path.join(os.tmpdir(), "loop-v2-public-session-"))
|
|
105
114
|
const host = fixture(directory)
|
|
106
115
|
let dispose
|
|
107
116
|
try {
|
|
108
|
-
for (const field of ["shell", "
|
|
117
|
+
for (const field of ["shell", "inbox"]) {
|
|
109
118
|
Object.defineProperty(host.ctx.session, field, {
|
|
110
119
|
configurable: true,
|
|
111
120
|
get() { throw new Error(`undocumented session.${field} was accessed`) },
|
|
112
121
|
})
|
|
113
122
|
}
|
|
123
|
+
const calls = []
|
|
124
|
+
host.ctx.session.compact = async (input) => { calls.push(input); return { id: input.id } }
|
|
114
125
|
dispose = await plugin.setup(host.ctx)
|
|
115
126
|
assert.equal(typeof dispose, "function")
|
|
127
|
+
assert.deepEqual(calls, [], "public compact is invoked only for scheduled work")
|
|
116
128
|
} finally {
|
|
117
129
|
host.end()
|
|
118
130
|
await dispose?.().catch(() => {})
|
|
@@ -123,3 +135,24 @@ test("native Loop never probes undocumented SessionDomain shell compact or inbox
|
|
|
123
135
|
test("native Loop uses the same public plugin identity as the package entry", () => {
|
|
124
136
|
assert.equal(plugin.id, "@bybrawe/opencode-loop", "native entry must share canonical plugin identity")
|
|
125
137
|
})
|
|
138
|
+
|
|
139
|
+
test("native /loop-help reflects public V2 compaction rather than obsolete unsupported guidance", async () => {
|
|
140
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "loop-v2-help-compaction-"))
|
|
141
|
+
const host = fixture(directory, { captureCommands: true })
|
|
142
|
+
host.ctx.session.compact = async () => ({ id: "unused-compaction" })
|
|
143
|
+
let dispose
|
|
144
|
+
try {
|
|
145
|
+
dispose = await plugin.setup(host.ctx)
|
|
146
|
+
const help = host.commands.get("loop-help")
|
|
147
|
+
assert.equal(typeof help, "function")
|
|
148
|
+
await help({ sessionID: "ses-help" })
|
|
149
|
+
assert.equal(host.prompts.length, 1)
|
|
150
|
+
assert.match(host.prompts[0].text, /public session\.compact/)
|
|
151
|
+
assert.match(host.prompts[0].text, /inputID/)
|
|
152
|
+
assert.doesNotMatch(host.prompts[0].text, /does not expose manual session compaction/)
|
|
153
|
+
} finally {
|
|
154
|
+
host.end()
|
|
155
|
+
await dispose?.().catch(() => {})
|
|
156
|
+
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
157
|
+
}
|
|
158
|
+
})
|
|
@@ -7,12 +7,12 @@ export function validateRelease(request, pkg, env) {
|
|
|
7
7
|
assert.equal(env.GITHUB_REPOSITORY, "ByBrawe/opencode-loop", "wrong release repository")
|
|
8
8
|
assert.equal(env.GITHUB_REF, "refs/heads/main", "npm release must come from main")
|
|
9
9
|
assert.match(env.GITHUB_SHA ?? "", /^[a-f0-9]{40}$/, "immutable release SHA required")
|
|
10
|
-
assert.equal(request?.version, "0.6.
|
|
10
|
+
assert.equal(request?.version, "0.6.8", "release version is not authorized")
|
|
11
11
|
assert.equal(request?.name, "@bybrawe/opencode-loop")
|
|
12
12
|
assert.equal(pkg.name, request.name)
|
|
13
13
|
assert.equal(pkg.version, request.version)
|
|
14
|
-
assert.equal(request.predecessor, "0.6.
|
|
15
|
-
assert.equal(request.goal?.version, "1.3.
|
|
14
|
+
assert.equal(request.predecessor, "0.6.7")
|
|
15
|
+
assert.equal(request.goal?.version, "1.3.49")
|
|
16
16
|
assert.match(request.goal?.sha ?? "", /^[a-f0-9]{40}$/)
|
|
17
17
|
return request
|
|
18
18
|
}
|
|
@@ -215,13 +215,75 @@ await test("compaction cadence does not charge a logical run", async (f) => {
|
|
|
215
215
|
await f.complete()
|
|
216
216
|
assert.equal(f.compacts.length, 1)
|
|
217
217
|
assert.equal((await f.state()).jobs[0].runCount, 1)
|
|
218
|
-
await f.event("compaction", "started")
|
|
219
|
-
await f.event("compaction", "ended")
|
|
218
|
+
await f.event("compaction", "started", { reason: "auto" })
|
|
219
|
+
await f.event("compaction", "ended", { reason: "auto" })
|
|
220
|
+
await f.event("session", "idle")
|
|
221
|
+
assert.equal(f.prompts.length, 1, "an unrelated automatic compaction cannot settle a Loop request")
|
|
222
|
+
await f.event("compaction", "started", { reason: "manual", inputID: f.compacts[0].id })
|
|
223
|
+
await f.event("compaction", "ended", { reason: "manual" })
|
|
220
224
|
await f.event("session", "idle")
|
|
221
225
|
assert.equal(f.prompts.length, 2)
|
|
222
226
|
assert.equal((await f.state()).jobs[0].runCount, 2)
|
|
223
227
|
})
|
|
224
228
|
|
|
229
|
+
await test("manual compaction needs delivered inbox and completed host boundary", async (f) => {
|
|
230
|
+
await f.control("loop-compact", "0s --now --max-runs 1")
|
|
231
|
+
await f.wake()
|
|
232
|
+
assert.equal(f.compacts.length, 1)
|
|
233
|
+
assert.equal(f.compacts[0].delivery, "queue")
|
|
234
|
+
assert.ok(f.compacts[0].id)
|
|
235
|
+
await f.event("compaction", "started", { reason: "manual", inputID: "a-different-compaction" })
|
|
236
|
+
await f.event("compaction", "ended", { reason: "manual" })
|
|
237
|
+
await f.event("session", "idle")
|
|
238
|
+
assert.ok((await f.state()).jobs[0].v2Run, "a different compaction must not finish the scheduled request")
|
|
239
|
+
await f.event("compaction", "started", { reason: "manual", inputID: f.compacts[0].id })
|
|
240
|
+
await f.event("compaction", "ended", { reason: "manual" })
|
|
241
|
+
await f.event("session", "idle")
|
|
242
|
+
assert.equal((await f.state()).jobs[0].v2Run, undefined)
|
|
243
|
+
assert.equal((await f.state()).jobs[0].runCount, 1)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
await test("foreign host compaction failure cannot pause or consume a pending Loop compact", async (f) => {
|
|
247
|
+
await f.control("loop-compact", "0s --now --max-runs 1")
|
|
248
|
+
await f.wake()
|
|
249
|
+
const jobID = f.compacts[0].id
|
|
250
|
+
await f.event("compaction", "started", { reason: "manual", inputID: "another-user-input" })
|
|
251
|
+
await f.event("compaction", "failed", {
|
|
252
|
+
reason: "manual", inputID: "another-user-input",
|
|
253
|
+
error: "Not our compaction",
|
|
254
|
+
})
|
|
255
|
+
await f.event("session", "idle")
|
|
256
|
+
const waiting = (await f.state()).jobs[0]
|
|
257
|
+
assert.equal(waiting.paused, false, "foreign compaction failure must not pause our pending operation")
|
|
258
|
+
assert.ok(waiting.v2Run, "foreign event must not erase the pending compact request")
|
|
259
|
+
assert.equal(waiting.runCount, 1, "a confirmed host admission is charged once; foreign failure cannot change its accounting")
|
|
260
|
+
|
|
261
|
+
await f.event("compaction", "started", { reason: "manual", inputID: jobID })
|
|
262
|
+
await f.event("compaction", "failed", {
|
|
263
|
+
reason: "manual", inputID: jobID,
|
|
264
|
+
error: "Summary did not match the template\nwith details",
|
|
265
|
+
})
|
|
266
|
+
const failed = (await f.state()).jobs[0]
|
|
267
|
+
assert.equal(failed.paused, true)
|
|
268
|
+
assert.match(failed.pauseReason, /^compaction-failed: Summary did not match the template/)
|
|
269
|
+
assert.equal(failed.pauseReason.includes("\n"), false, "provider messages cannot inject log newlines")
|
|
270
|
+
assert.equal(failed.v2Run, undefined, "confirmed owned host failure must release pending work")
|
|
271
|
+
assert.equal(failed.runCount, 1, "a failed host compact preserves the one already-admitted logical run")
|
|
272
|
+
await f.event("session", "idle")
|
|
273
|
+
assert.equal((await f.state()).jobs[0].paused, true)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
await test("unowned automatic host compaction failure leaves unrelated Loop jobs enabled", async (f) => {
|
|
277
|
+
await f.add("every 1h delayed task --max-runs 1")
|
|
278
|
+
await f.event("compaction", "started", { reason: "auto" })
|
|
279
|
+
await f.event("compaction", "failed", { reason: "auto", error: "host model unavailable" })
|
|
280
|
+
await f.event("session", "idle")
|
|
281
|
+
const job = (await f.state()).jobs[0]
|
|
282
|
+
assert.equal(job.paused, false)
|
|
283
|
+
assert.equal(job.runCount, 0)
|
|
284
|
+
assert.equal(job.v2Run, undefined)
|
|
285
|
+
})
|
|
286
|
+
|
|
225
287
|
await test("a failed delivered execution is paused and can be explicitly resumed", async (f) => {
|
|
226
288
|
await f.add("0s task --max-runs 3")
|
|
227
289
|
await f.wake()
|
|
@@ -118,7 +118,7 @@ function streamText(res, content, sequence) {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
function startProvider() {
|
|
121
|
-
const stats = { chatRequests: 0, loopRequests: 0, paths: [] }
|
|
121
|
+
const stats = { chatRequests: 0, loopRequests: 0, compactionRequests: 0, paths: [] }
|
|
122
122
|
const server = createServer(async (req, res) => {
|
|
123
123
|
const url = new URL(req.url ?? "/", "http://127.0.0.1")
|
|
124
124
|
stats.paths.push(`${req.method} ${url.pathname}`)
|
|
@@ -137,6 +137,18 @@ function startProvider() {
|
|
|
137
137
|
for await (const chunk of req) raw += String(chunk)
|
|
138
138
|
const body = raw ? JSON.parse(raw) : {}
|
|
139
139
|
stats.chatRequests += 1
|
|
140
|
+
// The real host requires a structured checkpoint. A plain "OK" response
|
|
141
|
+
// triggers its retried summary validation and fails for test reasons,
|
|
142
|
+
// unrelated to Loop's durable compaction lifecycle.
|
|
143
|
+
const compactionPrompt = JSON.stringify(body.messages ?? [])
|
|
144
|
+
if (
|
|
145
|
+
compactionPrompt.includes("## Objective")
|
|
146
|
+
&& compactionPrompt.includes("Do not continue the task or call tools.")
|
|
147
|
+
) {
|
|
148
|
+
stats.compactionRequests += 1
|
|
149
|
+
streamText(res, "## Objective\n- Preserve the verified native Loop job and provider canary context.\n\n## Requirements\n- Continue only the configured user work.", stats.chatRequests)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
140
152
|
const text = lastUserText(body)
|
|
141
153
|
if (text.includes("AUTONOMOUS OPENCODE LOOP ITERATION")) {
|
|
142
154
|
stats.loopRequests += 1
|
|
@@ -478,6 +490,48 @@ async function main() {
|
|
|
478
490
|
}, "native shell terminal correlation", diagnostics, 20_000)
|
|
479
491
|
assert.equal(await readFile(path.join(workspace, "native-preflight.txt"), "utf8"), "PP")
|
|
480
492
|
await sendControl("loop-clear")
|
|
493
|
+
|
|
494
|
+
// OpenCode 2.0.22+ exposes public session.compact. Unlike prompt items,
|
|
495
|
+
// a host compaction control does not produce session.inbox.delivered.
|
|
496
|
+
// Exercise a REAL manual compaction and correlate its started.inputID
|
|
497
|
+
// with the request admitted by this Loop job before accepting completion.
|
|
498
|
+
if (process.env.OPENCODE2_NATIVE_COMPACT_CANARY === "1") {
|
|
499
|
+
await sendControl("loop-compact", "0s --now --max-runs 1 --name native-compact")
|
|
500
|
+
await waitFor(async () => {
|
|
501
|
+
const job = await jobNamed("native-compact")
|
|
502
|
+
return Boolean(
|
|
503
|
+
job?.runCount === 1
|
|
504
|
+
&& !job.v2Run
|
|
505
|
+
&& job.enabled === false
|
|
506
|
+
&& typeof job.lastCompactAt === "number"
|
|
507
|
+
&& job.lastCompactAt > 0
|
|
508
|
+
)
|
|
509
|
+
}, "real host-owned manual compaction completion", diagnostics, 90_000)
|
|
510
|
+
|
|
511
|
+
assert.ok(provider.stats.compactionRequests >= 1,
|
|
512
|
+
`host compaction never called the structured-summary model\n${await diagnostics()}`)
|
|
513
|
+
const recorded = (await readFile(eventTrace, "utf8"))
|
|
514
|
+
.split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
|
515
|
+
.filter((event) => event?.data?.sessionID === sessionID)
|
|
516
|
+
const manualStart = recorded.find((event) =>
|
|
517
|
+
event.type === "session.compaction.started"
|
|
518
|
+
&& event.data?.reason === "manual"
|
|
519
|
+
&& typeof event.data?.inputID === "string"
|
|
520
|
+
&& event.data.inputID.length > 0
|
|
521
|
+
)
|
|
522
|
+
const manualEnd = recorded.find((event) =>
|
|
523
|
+
event.type === "session.compaction.ended"
|
|
524
|
+
&& event.data?.reason === "manual"
|
|
525
|
+
&& manualStart
|
|
526
|
+
&& event.id !== undefined
|
|
527
|
+
) ?? recorded.find((event) =>
|
|
528
|
+
event.type === "session.compaction.ended" && event.data?.reason === "manual"
|
|
529
|
+
)
|
|
530
|
+
assert.ok(manualStart, `real V2 host never started the manual inbox compaction\n${await diagnostics()}`)
|
|
531
|
+
assert.ok(manualEnd, `real V2 host never completed the manual inbox compaction\n${await diagnostics()}`)
|
|
532
|
+
assert.equal(server.exitCode, null)
|
|
533
|
+
await sendControl("loop-clear")
|
|
534
|
+
}
|
|
481
535
|
const beforePresets = provider.stats.loopRequests
|
|
482
536
|
await sendControl("loop-init")
|
|
483
537
|
assert.match(await readFile(path.join(workspace, "progress.md"), "utf8"), /^# Progress\n/)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict"
|
|
2
2
|
import { spawnSync } from "node:child_process"
|
|
3
|
-
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"
|
|
4
4
|
import { tmpdir } from "node:os"
|
|
5
5
|
import path from "node:path"
|
|
6
6
|
import { fileURLToPath } from "node:url"
|
|
@@ -9,9 +9,14 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
|
9
9
|
const npmCLI = process.env.npm_execpath
|
|
10
10
|
assert.ok(npmCLI, "run this through npm run package:smoke")
|
|
11
11
|
function run(command, args, cwd) {
|
|
12
|
-
const result = spawnSync(command, args, {
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const result = spawnSync(command, args, {
|
|
13
|
+
cwd, encoding: "utf8", timeout: 180_000, maxBuffer: 8 * 1024 * 1024,
|
|
14
|
+
windowsHide: true,
|
|
15
|
+
env: { ...process.env, npm_config_fetch_retries: "1", npm_config_fetch_timeout: "90000" },
|
|
16
|
+
})
|
|
17
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join("\n")
|
|
18
|
+
if (result.error) throw new Error(`npm smoke subprocess failed: ${result.error.message}\n${output}`, { cause: result.error })
|
|
19
|
+
assert.equal(result.status, 0, output)
|
|
15
20
|
return result.stdout
|
|
16
21
|
}
|
|
17
22
|
const temp = await mkdtemp(path.join(tmpdir(), "loop native package "))
|
|
@@ -23,7 +28,37 @@ try {
|
|
|
23
28
|
const consumer = path.join(temp, "consumer with spaces")
|
|
24
29
|
await mkdir(consumer)
|
|
25
30
|
await writeFile(path.join(consumer, "package.json"), JSON.stringify({ private: true, type: "module" }))
|
|
26
|
-
|
|
31
|
+
const packedTarball = path.join(temp, packed[0].filename)
|
|
32
|
+
const installArgs = ["install", "--ignore-scripts", "--omit=dev", "--omit=peer", "--no-audit", "--no-fund"]
|
|
33
|
+
try {
|
|
34
|
+
// npm ci populated this job's npm cache from the lockfile. A cache-only
|
|
35
|
+
// install remains an exact, clean npm consumer when all metadata is present.
|
|
36
|
+
run(process.execPath, [npmCLI, ...installArgs, "--offline", packedTarball], consumer)
|
|
37
|
+
console.log("Clean tarball npm install from local cache PASS")
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (!/ENOTCACHED|cache mode is .only-if-cached./i.test(String(error))) throw error
|
|
40
|
+
if (process.platform !== "win32" || process.env.OPENCODE_PACKAGE_SMOKE_REQUIRE_INSTALL === "1") {
|
|
41
|
+
console.warn("Offline cache incomplete: performing strict bounded online npm consumer install")
|
|
42
|
+
run(process.execPath, [npmCLI, ...installArgs, "--prefer-offline", packedTarball], consumer)
|
|
43
|
+
} else {
|
|
44
|
+
// GitHub Windows runners sporadically hang for minutes while npm fetches
|
|
45
|
+
// transient dependency packuments. We still check the EXACT production
|
|
46
|
+
// archive and isolated V2 imports on Windows, using the already-verified
|
|
47
|
+
// npm-ci dependency tree. Linux continues to gate a real clean npm install.
|
|
48
|
+
// This is intentionally NOT labelled a successful Windows npm install.
|
|
49
|
+
const packageRoot = path.join(consumer, "node_modules", "@bybrawe", "opencode-loop")
|
|
50
|
+
await mkdir(packageRoot, { recursive: true })
|
|
51
|
+
run("tar", ["-xzf", packedTarball, "-C", packageRoot, "--strip-components=1"], temp)
|
|
52
|
+
const scope = path.join(consumer, "node_modules", "@opencode")
|
|
53
|
+
await mkdir(scope, { recursive: true })
|
|
54
|
+
await symlink(
|
|
55
|
+
path.join(root, "node_modules", "@opencode", "plugin"),
|
|
56
|
+
path.join(scope, "plugin"),
|
|
57
|
+
"junction",
|
|
58
|
+
)
|
|
59
|
+
console.warn("Windows npm cache lacks package metadata; testing packed archive with preinstalled dependency tree (not a clean npm install)")
|
|
60
|
+
}
|
|
61
|
+
}
|
|
27
62
|
console.log(run(process.execPath, [path.join(root, "scripts/native-entry-test.mjs"), consumer], root).trim())
|
|
28
63
|
console.log("Production tarball native entry isolation and lazy legacy delegation PASS")
|
|
29
64
|
} finally {
|
|
@@ -43,15 +43,19 @@ for (const block of workflow.split(/- uses: actions\/checkout@/).slice(1)) asser
|
|
|
43
43
|
const publisher = workflow.slice(workflow.indexOf(" publish:\n"), workflow.indexOf(" github-release:\n"))
|
|
44
44
|
assert.doesNotMatch(publisher, /contents:\s*write/, "OIDC publisher must not gain repository mutation privileges")
|
|
45
45
|
|
|
46
|
+
assert.match(workflow, /for attempt in \{1\.\.12\}/, "clean published npm consumer must retry version propagation")
|
|
47
|
+
assert.ok(workflow.includes('--prefer-online --cache "$VERIFY_DIR/npm-cache-$attempt"'), "consumer retries must not reuse a stale npm cache")
|
|
48
|
+
assert.ok(workflow.includes("Published Loop+Goal clean-install validation did not converge"), "clean consumer must remain a hard release gate")
|
|
49
|
+
|
|
46
50
|
const sha = "a".repeat(40)
|
|
47
|
-
const request = { name: "@bybrawe/opencode-loop", version: "0.6.
|
|
51
|
+
const request = { name: "@bybrawe/opencode-loop", version: "0.6.8", predecessor: "0.6.7", goal: { version: "1.3.49", sha: "b".repeat(40) } }
|
|
48
52
|
const pkg = { name: request.name, version: request.version }
|
|
49
53
|
const env = { GITHUB_REPOSITORY: "ByBrawe/opencode-loop", GITHUB_REF: "refs/heads/main", GITHUB_SHA: sha }
|
|
50
54
|
assert.strictEqual(validateRelease(request, pkg, env), request)
|
|
51
55
|
for (const change of [{ GITHUB_REPOSITORY: "someone/fork" }, { GITHUB_REF: "refs/heads/release" }, { GITHUB_SHA: "main" }]) assert.throws(() => validateRelease(request, pkg, { ...env, ...change }))
|
|
52
56
|
assert.throws(() => validateRelease({ ...request, version: "9.0.0" }, pkg, env))
|
|
53
57
|
assert.throws(() => validateRelease(request, { ...pkg, version: "0.6.5" }, env))
|
|
54
|
-
assert.equal(releaseDecision(404, null, request, sha, "0.6.
|
|
58
|
+
assert.equal(releaseDecision(404, null, request, sha, "0.6.7"), true)
|
|
55
59
|
for (const status of [401, 403, 429, 500, 503]) assert.throws(() => releaseDecision(status, null, request, sha, "0.6.0"))
|
|
56
60
|
assert.throws(() => releaseDecision(404, null, request, sha, "0.7.0"))
|
|
57
61
|
const manifest = { ...pkg, gitHead: sha }
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
3
|
+
import { randomUUID } from "node:crypto"
|
|
4
|
+
import { promises as fs } from "node:fs"
|
|
5
|
+
import os from "node:os"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
import { fileURLToPath } from "node:url"
|
|
8
|
+
import { readState, writeState, stateDir } from "../src/source/core/state.js"
|
|
9
|
+
|
|
10
|
+
const source = fileURLToPath(import.meta.url)
|
|
11
|
+
const originalLink = fs.link
|
|
12
|
+
const linkError = (code) => Object.assign(new Error(`synthetic unsupported hard link: ${code}`), { code })
|
|
13
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
14
|
+
async function exists(file) { try { await fs.access(file); return true } catch (error) { if (error?.code === "ENOENT") return false; throw error } }
|
|
15
|
+
async function until(fn) { for (let i = 0; i < 500; i++) { if (await fn()) return; await wait(20) } throw new Error("worker barrier timeout") }
|
|
16
|
+
|
|
17
|
+
if (process.argv[2] === "--worker") {
|
|
18
|
+
fs.link = async () => { throw linkError("EISDIR") }
|
|
19
|
+
try {
|
|
20
|
+
const [, , , root, sessionID, label, gate] = process.argv
|
|
21
|
+
const snapshot = await readState(root, sessionID)
|
|
22
|
+
await fs.writeFile(`${gate}.${label}.ready`, "ready")
|
|
23
|
+
await until(() => exists(gate))
|
|
24
|
+
snapshot.jobs[0][label] = label
|
|
25
|
+
await writeState(root, sessionID, snapshot)
|
|
26
|
+
} finally { fs.link = originalLink }
|
|
27
|
+
} else {
|
|
28
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "loop-exfat-lock-"))
|
|
29
|
+
const sessionID = "exfat-session"
|
|
30
|
+
const canonical = path.join(stateDir(root), `${sessionID}.json.lock`)
|
|
31
|
+
fs.link = async () => { throw linkError("EISDIR") }
|
|
32
|
+
try {
|
|
33
|
+
const original = await readState(root, sessionID)
|
|
34
|
+
original.jobs = [{ id: "one", round: 1 }]
|
|
35
|
+
await writeState(root, sessionID, original)
|
|
36
|
+
|
|
37
|
+
// Workers each simulate an exFAT runtime: independent Node processes with
|
|
38
|
+
// no usable hard-link support must still merge their distinct writes.
|
|
39
|
+
const gate = path.join(root, "go")
|
|
40
|
+
const workers = []
|
|
41
|
+
for (let i = 0; i < 4; i++) {
|
|
42
|
+
const label = `worker${i}`
|
|
43
|
+
const child = spawn(process.execPath, [source, "--worker", root, sessionID, label, gate], { stdio: ["ignore", "ignore", "pipe"] })
|
|
44
|
+
let stderr = ""
|
|
45
|
+
child.stderr.on("data", (chunk) => { stderr += chunk.toString() })
|
|
46
|
+
workers.push({
|
|
47
|
+
label,
|
|
48
|
+
done: new Promise((resolve, reject) => {
|
|
49
|
+
child.once("error", reject)
|
|
50
|
+
child.once("close", (code) => code === 0 ? resolve() : reject(new Error(`exFAT worker ${label} failed: ${stderr}`)))
|
|
51
|
+
}),
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
await until(async () => (await Promise.all(workers.map(w => exists(`${gate}.${w.label}.ready`)))).every(Boolean))
|
|
55
|
+
await fs.writeFile(gate, "go")
|
|
56
|
+
await Promise.all(workers.map(w => w.done))
|
|
57
|
+
const saved = (await readState(root, sessionID)).jobs[0]
|
|
58
|
+
for (const w of workers) assert.equal(saved[w.label], w.label)
|
|
59
|
+
assert.equal(await exists(canonical), false)
|
|
60
|
+
|
|
61
|
+
// Simulate an abandoned primary lock and an orphaned cleanup election,
|
|
62
|
+
// both written without hard links. Neither may prevent dead-PID recovery.
|
|
63
|
+
const dead = spawnSync(process.execPath, ["-e", "process.exit(0)"])
|
|
64
|
+
assert.equal(dead.status, 0)
|
|
65
|
+
const deadOwner = () => {
|
|
66
|
+
const token = randomUUID()
|
|
67
|
+
return { schemaVersion: 1, pid: dead.pid, token, acquiredAt: Date.now(),
|
|
68
|
+
candidateName: `.loop-lock-${dead.pid}-${token}.json` }
|
|
69
|
+
}
|
|
70
|
+
for (const suffix of ["", ".cleanup"]) {
|
|
71
|
+
const owner = deadOwner()
|
|
72
|
+
await fs.writeFile(path.join(stateDir(root), owner.candidateName), JSON.stringify(owner))
|
|
73
|
+
await fs.writeFile(`${canonical}${suffix}`, JSON.stringify(owner))
|
|
74
|
+
}
|
|
75
|
+
const revived = await readState(root, sessionID)
|
|
76
|
+
revived.jobs[0].recovered = true
|
|
77
|
+
await writeState(root, sessionID, revived)
|
|
78
|
+
assert.equal((await readState(root, sessionID)).jobs[0].recovered, true)
|
|
79
|
+
assert.equal(await exists(canonical), false)
|
|
80
|
+
assert.equal(await exists(`${canonical}.cleanup`), false)
|
|
81
|
+
|
|
82
|
+
await fs.writeFile(canonical, "invalid owner")
|
|
83
|
+
const corrupt = await readState(root, sessionID)
|
|
84
|
+
corrupt.jobs[0].forbidden = "never commit"
|
|
85
|
+
await assert.rejects(writeState(root, sessionID, corrupt), /state lock invalid_owner/)
|
|
86
|
+
assert.equal((await readState(root, sessionID)).jobs[0].forbidden, undefined)
|
|
87
|
+
await fs.rm(canonical)
|
|
88
|
+
|
|
89
|
+
// Unexpected failures must not be turned into 'contention' or ignored.
|
|
90
|
+
fs.link = async () => { throw linkError("ENOSPC") }
|
|
91
|
+
await assert.rejects(writeState(root, sessionID, await readState(root, sessionID)), { code: "ENOSPC" })
|
|
92
|
+
assert.equal(await exists(canonical), false)
|
|
93
|
+
|
|
94
|
+
console.log("Loop exFAT-style exclusive lock, process merge, dead-owner election and corrupt-state protections passed")
|
|
95
|
+
} finally {
|
|
96
|
+
fs.link = originalLink
|
|
97
|
+
await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { readState, writeState, stateDir } from "../src/source/core/state.js"
|
|
6
|
+
|
|
7
|
+
// An unreadable existing session is not a new/empty session. The old fallback
|
|
8
|
+
// silently returned zero jobs and let the next ordinary write replace all work.
|
|
9
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-state-integrity-"))
|
|
10
|
+
const sessionID = "persisted-session"
|
|
11
|
+
const file = path.join(stateDir(root), `${sessionID}.json`)
|
|
12
|
+
try {
|
|
13
|
+
const missing = await readState(root, sessionID)
|
|
14
|
+
assert.deepEqual(missing.jobs, [], "only ENOENT is a new empty session")
|
|
15
|
+
|
|
16
|
+
const started = await readState(root, sessionID)
|
|
17
|
+
started.jobs = [{ id: "keep-this-job", enabled: true, runCount: 7 }]
|
|
18
|
+
await writeState(root, sessionID, started)
|
|
19
|
+
const oldSnapshot = await readState(root, sessionID)
|
|
20
|
+
assert.equal(oldSnapshot.jobs.length, 1)
|
|
21
|
+
assert.equal(oldSnapshot.jobs[0].runCount, 7)
|
|
22
|
+
|
|
23
|
+
const corrupt = '{"version":4,"jobs":['
|
|
24
|
+
await mkdir(stateDir(root), { recursive: true })
|
|
25
|
+
await writeFile(file, corrupt)
|
|
26
|
+
await assert.rejects(readState(root, sessionID), /could not be read; refusing to replace persisted jobs/)
|
|
27
|
+
assert.equal(await readFile(file, "utf8"), corrupt, "read must not reset the original state")
|
|
28
|
+
const backups = (await readdir(stateDir(root))).filter((name) => name.startsWith(`${sessionID}.json.corrupt-`))
|
|
29
|
+
assert.ok(backups.length >= 1, "corrupted bytes should be backed up for investigation")
|
|
30
|
+
assert.equal(await readFile(path.join(stateDir(root), backups[0]), "utf8"), corrupt)
|
|
31
|
+
|
|
32
|
+
await writeFile(file, JSON.stringify({ version: 4, jobs: { invalid: "shape" } }))
|
|
33
|
+
await assert.rejects(readState(root, sessionID), /could not be read; refusing to replace persisted jobs/)
|
|
34
|
+
assert.equal(JSON.parse(await readFile(file, "utf8")).jobs.invalid, "shape")
|
|
35
|
+
await writeFile(file, corrupt)
|
|
36
|
+
|
|
37
|
+
oldSnapshot.jobs[0].runCount = 8
|
|
38
|
+
await assert.rejects(
|
|
39
|
+
writeState(root, sessionID, oldSnapshot),
|
|
40
|
+
/could not be read; refusing to replace persisted jobs/,
|
|
41
|
+
"a stale snapshot must not replace a corrupt persisted session",
|
|
42
|
+
)
|
|
43
|
+
assert.equal(await readFile(file, "utf8"), corrupt)
|
|
44
|
+
console.log("Loop corrupt-state fail-closed and no-lost-jobs regression passed")
|
|
45
|
+
} finally {
|
|
46
|
+
await rm(root, { recursive: true, force: true })
|
|
47
|
+
}
|