tardie 0.2.0-rc → 0.2.1
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/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/commands.ts +6 -1
- package/src/cli/dev.ts +14 -1
- package/src/server/host.ts +76 -20
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ tdg init researcher
|
|
|
47
47
|
cd researcher
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
The `init` command creates `researcher/actor.ts` from the bundled
|
|
50
|
+
The `init` command creates `researcher/actor.ts` from the bundled template. Read the [Quickstart guide](docs/quickstart.md) to understand the framework, then edit `actor.ts` to describe the agent. Build and push the result into the local actor registry:
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
53
|
tdg build actor.ts
|
package/package.json
CHANGED
package/src/cli/commands.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { modelIsConfigured } from "tardie/server/host"
|
|
|
6
6
|
|
|
7
7
|
import { buildActor, buildSummary, DEFAULT_BUILD_DIRECTORY } from "./build"
|
|
8
8
|
import { readFileConfig, resolveRemote, resolveServer } from "./config"
|
|
9
|
-
import { availableDevPort, DEFAULT_MIN_PORT, DEV_URL_HOST, dev, openBrowser } from "./dev"
|
|
9
|
+
import { availableDevPort, DEFAULT_ACTOR_REFRESH_MILLIS, DEFAULT_MIN_PORT, DEV_URL_HOST, dev, openBrowser } from "./dev"
|
|
10
10
|
import { initActor, initSummary } from "./init"
|
|
11
11
|
import { DEFAULT_ACTOR_DIRECTORY, pushActor, pushSummary, PUSH_TARGETS } from "./push"
|
|
12
12
|
import { homeOf, HOME_MISSING, setupJson, setupPrompt, setupSummary, writeSetup } from "./setup"
|
|
@@ -278,6 +278,10 @@ export const devCommand = Command.make("dev", {
|
|
|
278
278
|
Flag.withDescription("The directory holding pushed actor databases. Defaults to TARDIGRADE_ACTOR_DATA."),
|
|
279
279
|
Flag.optional
|
|
280
280
|
),
|
|
281
|
+
actorRefreshMillis: Flag.integer("actor-refresh-ms").pipe(
|
|
282
|
+
Flag.withDescription("Milliseconds to wait after a local actor change before refreshing the registry."),
|
|
283
|
+
Flag.withDefault(DEFAULT_ACTOR_REFRESH_MILLIS)
|
|
284
|
+
),
|
|
281
285
|
ui: Flag.string("ui").pipe(
|
|
282
286
|
Flag.withDescription("The directory holding the built UI. Defaults to the build shipped beside this command."),
|
|
283
287
|
Flag.optional
|
|
@@ -339,6 +343,7 @@ export const devCommand = Command.make("dev", {
|
|
|
339
343
|
try: () => dev({
|
|
340
344
|
config: config2,
|
|
341
345
|
assets: stated(flags.ui),
|
|
346
|
+
actorRefreshMillis: flags.actorRefreshMillis,
|
|
342
347
|
...(flags.open ? { onListen: openBrowser } : {})
|
|
343
348
|
}),
|
|
344
349
|
catch: userErrorOf
|
package/src/cli/dev.ts
CHANGED
|
@@ -26,6 +26,10 @@ export const DEV_URL_HOST = "localhost"
|
|
|
26
26
|
// occupied. The `--min-port` flag lets a caller narrow this range.
|
|
27
27
|
export const DEFAULT_MIN_PORT = 1024
|
|
28
28
|
|
|
29
|
+
// DEFAULT_ACTOR_REFRESH_MILLIS lets an atomic local push finish its directory swaps before tdg dev
|
|
30
|
+
// reconciles the actor root. DevOptions and --actor-refresh-ms can replace it.
|
|
31
|
+
export const DEFAULT_ACTOR_REFRESH_MILLIS = 50
|
|
32
|
+
|
|
29
33
|
// The status that means the router matched nothing. It is the seam the UI is served through: the
|
|
30
34
|
// declared routes answer first, and only a path none of them owns reaches the build.
|
|
31
35
|
export const UNMATCHED = 404
|
|
@@ -110,6 +114,8 @@ export interface DevOptions {
|
|
|
110
114
|
readonly assets?: string | undefined
|
|
111
115
|
// The model seam, which a test binds to a scripted mind (apps/server/src/host.ts, ThreadsOptions).
|
|
112
116
|
readonly threads?: ThreadsOptions | undefined
|
|
117
|
+
// actorRefreshMillis is the visible debounce applied to local actor-root changes.
|
|
118
|
+
readonly actorRefreshMillis?: number | undefined
|
|
113
119
|
readonly disableLogger?: boolean | undefined
|
|
114
120
|
readonly disableListenLog?: boolean | undefined
|
|
115
121
|
// onListen receives the UI URL after the server owns its listening socket.
|
|
@@ -120,9 +126,16 @@ export interface DevOptions {
|
|
|
120
126
|
// Layer rather than a running process, so the caller owns the scope and the process that stops
|
|
121
127
|
// listening stops writing (apps/server/src/host.ts, layerThreads).
|
|
122
128
|
export const dev = (options: DevOptions) => {
|
|
129
|
+
const actorRefreshMillis = options.actorRefreshMillis ?? DEFAULT_ACTOR_REFRESH_MILLIS
|
|
130
|
+
if (!Number.isInteger(actorRefreshMillis) || actorRefreshMillis < 0) {
|
|
131
|
+
throw new Error(`actor refresh must be a non-negative integer, got ${actorRefreshMillis}`)
|
|
132
|
+
}
|
|
123
133
|
const root = resolveAssets(options.assets)
|
|
124
134
|
const config = layerConfig(options.config)
|
|
125
|
-
const threads = Layer.provide(layerThreads(
|
|
135
|
+
const threads = Layer.provide(layerThreads({
|
|
136
|
+
...options.threads,
|
|
137
|
+
actorRefresh: { debounceMillis: actorRefreshMillis }
|
|
138
|
+
}), config)
|
|
126
139
|
// provideMerge rather than provide: the listening server stays visible in the layer's own
|
|
127
140
|
// services, which is what lets a caller read the address it was given when it asked for port 0
|
|
128
141
|
// (dev.test.ts).
|
package/src/server/host.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Clock, Context, Effect, Layer } from "effect"
|
|
|
2
2
|
import { FetchHttpClient } from "effect/unstable/http"
|
|
3
3
|
import { BunFileSystem, BunPath } from "@effect/platform-bun"
|
|
4
4
|
import { createHash } from "node:crypto"
|
|
5
|
+
import { watch, type FSWatcher } from "node:fs"
|
|
5
6
|
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"
|
|
6
7
|
import { join, resolve } from "node:path"
|
|
7
8
|
import { pathToFileURL } from "node:url"
|
|
@@ -102,6 +103,12 @@ export interface ThreadsOptions {
|
|
|
102
103
|
// derivation whole, which is how a test runs a scripted mind with no credentials
|
|
103
104
|
// (host.test.ts). It is the one seam because Infer is the one place a turn leaves the process.
|
|
104
105
|
readonly infer?: Layer.Layer<Infer>
|
|
106
|
+
// actorRefresh watches the actor root and reconciles its artifacts after the stated debounce.
|
|
107
|
+
// Absent keeps a hosted server's registry fixed except for PUT /v1/actors; tdg dev supplies it.
|
|
108
|
+
readonly actorRefresh?: {
|
|
109
|
+
readonly debounceMillis: number
|
|
110
|
+
readonly onError?: ((error: Error) => void) | undefined
|
|
111
|
+
} | undefined
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
interface ActorRuntime {
|
|
@@ -240,41 +247,91 @@ const make = (options: ThreadsOptions) =>
|
|
|
240
247
|
const lane = layerLane(config, options)
|
|
241
248
|
const runtimes = new Map<string, ActorRuntime>()
|
|
242
249
|
const builtIn = assemblyOf()
|
|
250
|
+
const root = resolve(config.actors)
|
|
251
|
+
let mutations: Promise<void> = Promise.resolve()
|
|
252
|
+
const exclusive = <A>(operation: () => Promise<A>): Promise<A> => {
|
|
253
|
+
const result = mutations.then(operation, operation)
|
|
254
|
+
mutations = result.then(() => undefined, () => undefined)
|
|
255
|
+
return result
|
|
256
|
+
}
|
|
243
257
|
const open = async (summary: ActorSummary, actor: Actor<ServerR>, log: string): Promise<ActorRuntime> => {
|
|
244
258
|
const runtime = await runtimeOf(summary, actor, log, lane)
|
|
245
259
|
runtimes.set(summary.name, runtime)
|
|
246
260
|
return runtime
|
|
247
261
|
}
|
|
262
|
+
const load = async (directory: string): Promise<{ readonly summary: ActorSummary; readonly actor: Actor<ServerR> }> => {
|
|
263
|
+
const artifact = await manifestOf(directory)
|
|
264
|
+
if (artifact.manifest.name === RESERVED_ACTOR) throw new Error(`${RESERVED_ACTOR} is reserved for the built-in actor`)
|
|
265
|
+
const definition = await definitionOf(join(directory, artifact.manifest.module), artifact.manifest)
|
|
266
|
+
return {
|
|
267
|
+
summary: { name: definition.name, builtIn: false, digest: artifact.manifest.digest },
|
|
268
|
+
actor: definition.actor
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const replace = async (summary: ActorSummary, actor: Actor<ServerR>): Promise<void> => {
|
|
272
|
+
const current = runtimes.get(summary.name)
|
|
273
|
+
if (current?.summary.digest === summary.digest) return
|
|
274
|
+
if (current !== undefined) {
|
|
275
|
+
await current.close()
|
|
276
|
+
runtimes.delete(summary.name)
|
|
277
|
+
}
|
|
278
|
+
await open(summary, actor, join(resolve(config.actorData), `${summary.name}.sqlite`))
|
|
279
|
+
}
|
|
280
|
+
const synchronize = async (): Promise<void> => {
|
|
281
|
+
const entries = await readdir(root, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
|
|
282
|
+
if (error.code === "ENOENT") return []
|
|
283
|
+
throw error
|
|
284
|
+
})
|
|
285
|
+
const found = new Set<string>()
|
|
286
|
+
for (const entry of entries) {
|
|
287
|
+
if (!entry.isDirectory() || !ACTOR_NAME_PATTERN.test(entry.name)) continue
|
|
288
|
+
const loaded = await load(join(root, entry.name))
|
|
289
|
+
if (loaded.summary.name !== entry.name) throw new Error(`actor artifact name does not match directory ${JSON.stringify(entry.name)}`)
|
|
290
|
+
found.add(loaded.summary.name)
|
|
291
|
+
await replace(loaded.summary, loaded.actor)
|
|
292
|
+
}
|
|
293
|
+
for (const [name, runtime] of runtimes) {
|
|
294
|
+
if (name === RESERVED_ACTOR || found.has(name)) continue
|
|
295
|
+
await runtime.close()
|
|
296
|
+
runtimes.delete(name)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
let watcher: FSWatcher | undefined
|
|
300
|
+
let refreshTimer: ReturnType<typeof setTimeout> | undefined
|
|
248
301
|
yield* Effect.acquireRelease(
|
|
249
302
|
Effect.promise(async () => {
|
|
250
303
|
await open({ name: RESERVED_ACTOR, builtIn: true }, builtIn, config.db)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
)
|
|
304
|
+
await synchronize()
|
|
305
|
+
if (options.actorRefresh !== undefined) {
|
|
306
|
+
const { debounceMillis } = options.actorRefresh
|
|
307
|
+
if (!Number.isInteger(debounceMillis) || debounceMillis < 0) {
|
|
308
|
+
throw new Error(`actor refresh debounce must be a non-negative integer, got ${debounceMillis}`)
|
|
309
|
+
}
|
|
310
|
+
await mkdir(root, { recursive: true })
|
|
311
|
+
const report = options.actorRefresh.onError ?? ((error: Error) => console.error(`actor refresh failed: ${error.message}`))
|
|
312
|
+
watcher = watch(root, () => {
|
|
313
|
+
if (refreshTimer !== undefined) clearTimeout(refreshTimer)
|
|
314
|
+
refreshTimer = setTimeout(() => {
|
|
315
|
+
refreshTimer = undefined
|
|
316
|
+
void exclusive(synchronize).catch((error: unknown) => report(error instanceof Error ? error : new Error(String(error))))
|
|
317
|
+
}, debounceMillis)
|
|
318
|
+
})
|
|
267
319
|
}
|
|
268
320
|
return runtimes
|
|
269
321
|
}),
|
|
270
|
-
(opened) => Effect.promise(
|
|
322
|
+
(opened) => Effect.promise(async () => {
|
|
323
|
+
watcher?.close()
|
|
324
|
+
if (refreshTimer !== undefined) clearTimeout(refreshTimer)
|
|
325
|
+
await mutations
|
|
326
|
+
await Promise.all([...opened.values()].map((runtime) => runtime.close()))
|
|
327
|
+
})
|
|
271
328
|
)
|
|
272
329
|
|
|
273
330
|
const selected = (name: string): ActorThreads | undefined => runtimes.get(name)?.threads
|
|
274
331
|
const primary = selected(RESERVED_ACTOR)!
|
|
275
332
|
const push = (artifact: ActorArtifact): Effect.Effect<ActorSummary, Error> =>
|
|
276
333
|
Effect.tryPromise({
|
|
277
|
-
try: async () => {
|
|
334
|
+
try: () => exclusive(async () => {
|
|
278
335
|
const manifest = artifact.manifest as ActorArtifactManifest
|
|
279
336
|
if (manifest.schema !== ACTOR_ARTIFACT_VERSION) throw new Error(`unsupported actor artifact schema ${manifest.schema}`)
|
|
280
337
|
if (!ACTOR_NAME_PATTERN.test(manifest.name)) throw new Error(`actor name must match ${String(ACTOR_NAME_PATTERN)}`)
|
|
@@ -282,7 +339,6 @@ const make = (options: ThreadsOptions) =>
|
|
|
282
339
|
if (manifest.module !== "actor.mjs") throw new Error(`actor module must be ${JSON.stringify("actor.mjs")}`)
|
|
283
340
|
const actual = digestOf(artifact.module)
|
|
284
341
|
if (actual !== manifest.digest) throw new Error(`actor artifact digest mismatch: expected ${manifest.digest}, got ${actual}`)
|
|
285
|
-
const root = resolve(config.actors)
|
|
286
342
|
const destination = join(root, manifest.name)
|
|
287
343
|
const temporary = `${destination}.incoming`
|
|
288
344
|
const previous = `${destination}.previous`
|
|
@@ -313,7 +369,7 @@ const make = (options: ThreadsOptions) =>
|
|
|
313
369
|
await rm(temporary, { recursive: true, force: true })
|
|
314
370
|
throw error
|
|
315
371
|
}
|
|
316
|
-
},
|
|
372
|
+
}),
|
|
317
373
|
catch: (error) => error instanceof Error ? error : new Error(String(error))
|
|
318
374
|
})
|
|
319
375
|
|