Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@deepagent-code/app",
"version": "1.4.1",
"version": "1.4.2",
"description": "",
"type": "module",
"exports": {
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/agent-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,11 @@ export const configure = (config: Config = {}) => {
// knowledge/state diverge from project-memory whenever runsDir pointed outside <home>/runs.
const baseDir = config.baseDir ?? resolveDeepAgentCodeHome()
DeepAgentSessionState.configure(path.join(baseDir, "state"))
// I33-1: the structural plan lives in the single DocumentStore under <baseDir>/state/goal/<sid>/graph
// (co-located with the goal/run graph so the `plan` tool and the goal path write the SAME doc). Root
// it from the SAME baseDir/state session-state uses, so the two paths converge (planStoreRoot ≡
// goalStoreRoot). Must be set before any plan read/write.
DeepAgentPlanStore.configureRoot(path.join(baseDir, "state"))
// docs/34 §7.2/§8: durable knowledge stores root under baseDir (public/knowledge + project/<id>/
// knowledge). The retriever's knowledge-source reads from here. Same injected baseDir, no self-resolve.
DeepAgentKnowledgeSource.configure(baseDir)
Expand Down Expand Up @@ -386,6 +391,7 @@ import {
import * as DeepAgentOrchestrator from "./deepagent/orchestrator"
import * as DeepAgentSessionState from "./deepagent/session-state"
import * as DeepAgentPlanController from "./deepagent/plan-controller"
import * as DeepAgentPlanStore from "./deepagent/plan-store"
import * as DeepAgentDiagnosis from "./deepagent/diagnosis"
import * as DeepAgentFailureTriage from "./deepagent/failure-triage"
import * as DeepAgentValidation from "./deepagent/validation"
Expand Down Expand Up @@ -414,6 +420,7 @@ export {
DeepAgentOrchestrator,
DeepAgentSessionState,
DeepAgentPlanController,
DeepAgentPlanStore,
DeepAgentDiagnosis,
DeepAgentFailureTriage,
DeepAgentValidation,
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * as AgentV2 from "./agent"

import { Array, Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { makeLocationNode } from "./effect/app-node"
import { ModelV2 } from "./model"
import { PermissionSchema } from "./permission/schema"
import { ProviderV2 } from "./provider"
Expand Down Expand Up @@ -164,4 +165,6 @@ export const layer = Layer.effect(
}),
)

export const node = makeLocationNode({ service: Service, layer, deps: [] })

export const locationLayer = layer
3 changes: 3 additions & 0 deletions packages/core/src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * as CommandV2 from "./command"

import { Context, Effect, Layer, Schema } from "effect"
import { castDraft, type Draft } from "immer"
import { makeLocationNode } from "./effect/app-node"
import { ModelV2 } from "./model"
import { State } from "./state"

Expand Down Expand Up @@ -67,4 +68,6 @@ export const layer = Layer.effect(
}),
)

export const node = makeLocationNode({ service: Service, layer, deps: [] })

export const locationLayer = layer
7 changes: 7 additions & 0 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * as Config from "./config"
import path from "path"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { makeLocationNode } from "./effect/app-node"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Location } from "./location"
Expand Down Expand Up @@ -223,4 +224,10 @@ export const layer = Layer.effect(
}),
)

export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Global.node, Location.node, Policy.node],
})

export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer))
4 changes: 4 additions & 0 deletions packages/core/src/cross-spawn-spawner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
import * as NodeChildProcess from "node:child_process"
import { PassThrough } from "node:stream"
import launch from "cross-spawn"
import { makeGlobalNode } from "./effect/app-node"
import { filesystem, path } from "./effect/app-node-platform"

const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))

Expand Down Expand Up @@ -502,4 +504,6 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste

export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))

export const node = makeGlobalNode({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })

export * as CrossSpawnSpawner from "./cross-spawn-spawner"
3 changes: 3 additions & 0 deletions packages/core/src/database/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Flag } from "../flag/flag"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "../installation/version"
import { makeGlobalNode } from "../effect/app-node"

const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase>
Expand Down Expand Up @@ -58,3 +59,5 @@ export const defaultLayer = Layer.unwrap(
return layerFromPath(path())
}),
).pipe(Layer.provide(Global.defaultLayer))

export const node = makeGlobalNode({ service: Service, layer: layerFromPath(path()), deps: [] })
72 changes: 70 additions & 2 deletions packages/core/src/deepagent/atomic-write.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"
import { closeSync, fsyncSync, mkdirSync, openSync, renameSync, rmSync, writeSync } from "node:fs"
import { randomUUID } from "node:crypto"
import path from "node:path"

Expand All @@ -9,15 +9,83 @@ import path from "node:path"
// renameSync is atomic on POSIX and Windows. On any failure the temp file is removed and the error
// propagates (never swallow a lost durable write). This mirrors the temp+rename pattern already
// used by workspace.ts createProjectAtomically.
//
// F30-1 (deepagentcore-v4.0.3 storage prereq): the temp file is now fsync'd BEFORE the rename so a
// crash between write and rename can never expose a torn body, and the containing directory is
// fsync'd AFTER the rename (best-effort) so the rename itself is durable across a power loss. This
// makes writeFileAtomic the single crash-safe overwrite primitive the DocumentStore relies on.
export const writeFileAtomic = (file: string, content: string): void => {
const dir = path.dirname(file)
mkdirSync(dir, { recursive: true })
const tmp = path.join(dir, `.${path.basename(file)}.tmp-${process.pid}-${randomUUID()}`)
let fd: number | undefined
try {
writeFileSync(tmp, content, "utf-8")
fd = openSync(tmp, "w")
writeSync(fd, content, null, "utf-8")
fsyncSync(fd)
closeSync(fd)
fd = undefined
renameSync(tmp, file)
fsyncDir(dir)
} catch (error) {
if (fd !== undefined) {
try {
closeSync(fd)
} catch {
/* fd already invalid — nothing to salvage */
}
}
rmSync(tmp, { force: true })
throw error
}
}

// F30-1: crash-safe EXCLUSIVE create — the CAS write primitive for append-only version files.
// `openSync(file, "wx")` fails with EEXIST if the target already exists, giving an atomic
// compare-and-swap on the filesystem: two processes racing to write the same `id@vN.json` version
// file, one wins and the other observes EEXIST (the caller turns that into a conflict). The body is
// fsync'd before close so a crash can't leave a half-written version file. Throws the raw Node error
// (code "EEXIST" on collision) — the DocumentStore inspects `.code` to decide idempotent-vs-conflict.
export const writeFileExclusive = (file: string, content: string): void => {
const dir = path.dirname(file)
mkdirSync(dir, { recursive: true })
let fd: number | undefined
try {
fd = openSync(file, "wx")
writeSync(fd, content, null, "utf-8")
fsyncSync(fd)
closeSync(fd)
fd = undefined
fsyncDir(dir)
} catch (error) {
if (fd !== undefined) {
try {
closeSync(fd)
} catch {
/* fd already invalid */
}
}
throw error
}
}

// Best-effort directory fsync so a rename/create is durable. Not all platforms/filesystems permit
// opening a directory for fsync (Windows throws EPERM/EISDIR, some FUSE mounts reject it); a failure
// here does not compromise the file body (already fsync'd) so it is intentionally swallowed.
const fsyncDir = (dir: string): void => {
let dfd: number | undefined
try {
dfd = openSync(dir, "r")
fsyncSync(dfd)
} catch {
/* directory fsync unsupported on this platform — file body is already durable */
} finally {
if (dfd !== undefined) {
try {
closeSync(dfd)
} catch {
/* ignore */
}
}
}
}
63 changes: 62 additions & 1 deletion packages/core/src/deepagent/command-intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ const READ_ONLY_PREFIXES: ReadonlyArray<readonly string[]> = [
["rg"],
["ag"],
["ripgrep"],
// ── text stream inspection (read-only filters; parity with codex is_safe_to_call_with_exec) ──
// These only transform stdin/named files to stdout. `sort`/`sed` have file-writing variants and are
// guarded below; the rest cannot write the filesystem, process table, or environment.
["cut"],
["nl"],
["paste"],
["rev"],
["seq"],
["tr"],
["uniq"],
["comm"],
["fold"],
["expr"],
["true"],
["false"],
["column"],
["sort"], // read-only UNLESS it writes with -o/--output (guarded below)
["sed"], // mutating by default (in MUTATING_COMMANDS); the read-only `sed -n …p` form is allowed via a guard below
// ── environment / introspection (query forms only) ──
["which"],
["whereis"],
Expand Down Expand Up @@ -149,7 +167,10 @@ const MUTATING_COMMANDS = new Set<string>([
"shred",
"tee",
"install",
"sed", // sed alone is read-only, but `sed -i` mutates; treat as mutating unless we prove otherwise (guarded)
// NOTE: `sed` is intentionally NOT a blanket mutating command. `sed -i` mutates but `sed -n {N|M,N}p`
// is a pure read-only slice (the common "show me lines X..Y" probe). It is matched as a read-only
// prefix and then constrained by a guard in isReadOnlySegment to ONLY the `-n …p` form (parity with
// codex is_safe_to_call_with_exec). Any other sed invocation fails the guard → mutating.
"kill",
"killall",
"pkill",
Expand Down Expand Up @@ -217,6 +238,23 @@ const matchReadOnlyPrefix = (tokens: string[]): number => {
// `find` is read-only unless it carries an action predicate that executes or deletes.
const FIND_MUTATING_ACTIONS = new Set(["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprintf"])

// `sed -n {N|M,N}p` is a pure read-only line slice. Port of codex is_valid_sed_n_arg: the address
// must match /^(\d+,)?\d+p$/ — one or two numeric line numbers followed by the `p` (print) command,
// nothing else. Any other sed script can substitute/delete/write (`-i`, `w`, `s///w file`), so only
// this exact shape is treated as read-only.
const isValidSedNArg = (arg: string | undefined): boolean => {
if (arg == null) return false
const core = arg.endsWith("p") ? arg.slice(0, -1) : null
if (core == null) return false
const parts = core.split(",")
if (parts.length === 1) return parts[0].length > 0 && /^\d+$/.test(parts[0])
if (parts.length === 2) return parts[0].length > 0 && parts[1].length > 0 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])
return false
}

// `sort` writes a file with -o/--output; everything else it does is read-only (stdin/files → stdout).
const SORT_WRITE_LONG = /^--output(=|$)/

// `env` is read-only only as a bare query (`env`); `env FOO=bar cmd` runs a command with a mutated
// environment, so anything past the command word makes it mutating.
const isReadOnlySegment = (segment: string): boolean => {
Expand Down Expand Up @@ -257,6 +295,29 @@ const isReadOnlySegment = (segment: string): boolean => {
const rest = words.slice(1).filter((token) => !isFlag(token))
if (rest.length > 0) return false
}
// `sed` is read-only ONLY as `sed -n {N|M,N}p [file]` (line slice). The prefix matcher accepts a
// bare `sed`, so we must reject every other form here. Require exactly: -n, a valid `…p` address,
// and at most one trailing operand (the file). No -i, no -e, no `w`/`s///w`, no extra scripts.
if (head === "sed") {
const rest = words.slice(1)
const nIdx = rest.indexOf("-n")
if (nIdx === -1) return false
// Only -n may be a flag; any other flag (e.g. -i, -e, -E) makes it non-readonly.
if (rest.some((token, i) => i !== nIdx && isFlag(token))) return false
const nonFlags = rest.filter((token) => !isFlag(token))
// nonFlags[0] must be the address (…p); an optional nonFlags[1] is the file. More → reject.
if (nonFlags.length < 1 || nonFlags.length > 2) return false
if (!isValidSedNArg(nonFlags[0])) return false
}
// `sort -o file` / `sort --output=file` writes a file. Everything else sort does is read-only.
if (head === "sort") {
for (let i = 1; i < words.length; i++) {
const token = words[i]
if (token === "-o" || SORT_WRITE_LONG.test(token)) return false
// bundled short form `-ofile` (sort's only single-letter value-flag that writes)
if (/^-o./.test(token)) return false
}
}
// curl writes a file with -o/-O/--output/--remote-name. A short-flag TOKEN can glue or bundle the
// output flag (`-ofile.txt`, `-sofile`), and since curl short flags are single-letter and `o`/`O`
// are only ever the output flags, any single-dash bundle containing `o`/`O` writes a file. Match a
Expand Down
Loading
Loading