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 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 quickstart template. Edit it to describe the agent, then build and push it into the local actor registry:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tardie",
3
- "version": "0.2.0-rc",
3
+ "version": "0.2.1",
4
4
  "license": "MIT",
5
5
  "author": "Clavia, Inc.",
6
6
  "description": "A durable agent harness. State is a pure function of the log.",
@@ -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(options.threads ?? {}), config)
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).
@@ -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
- const root = resolve(config.actors)
252
- const entries = await readdir(root, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
253
- if (error.code === "ENOENT") return []
254
- throw error
255
- })
256
- for (const entry of entries) {
257
- if (!entry.isDirectory()) continue
258
- const directory = join(root, entry.name)
259
- const artifact = await manifestOf(directory)
260
- if (artifact.manifest.name === RESERVED_ACTOR) throw new Error(`${RESERVED_ACTOR} is reserved for the built-in actor`)
261
- const definition = await definitionOf(join(directory, artifact.manifest.module), artifact.manifest)
262
- await open(
263
- { name: definition.name, builtIn: false, digest: artifact.manifest.digest },
264
- definition.actor,
265
- join(resolve(config.actorData), `${definition.name}.sqlite`)
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(() => Promise.all([...opened.values()].map((runtime) => runtime.close())).then(() => undefined))
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