my-durable 0.0.0-stage → 0.0.2
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/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +328 -2
- package/ai_docs/durable_pi/announcement.md +860 -0
- package/ai_docs/durable_pi/changelog.md +51 -0
- package/ai_docs/durable_pi/chord-delta-findings.md +362 -0
- package/ai_docs/durable_pi/coding-agent-integration.md +70 -0
- package/ai_docs/durable_pi/pico-v5-chord-usage.md +391 -0
- package/ai_docs/durable_pi/pico-v5-handoff.md +921 -0
- package/ai_docs/durable_pi/readme.md +617 -0
- package/ai_docs/durable_pi/sources.md +22 -0
- package/ai_docs/durable_pi/spec.md +4696 -0
- package/ai_docs/durable_pi/vacation-planner.md +57 -0
- package/ai_docs/pi/chord.md +268 -0
- package/ai_docs/pi/cli-integration.md +110 -0
- package/ai_docs/pi/cli.md +325 -0
- package/ai_docs/pi/codemode.md +211 -0
- package/ai_docs/pi/compaction.md +467 -0
- package/ai_docs/pi/configuration.md +51 -0
- package/ai_docs/pi/containerization.md +187 -0
- package/ai_docs/pi/custom-provider.md +177 -0
- package/ai_docs/pi/environment-variables.md +102 -0
- package/ai_docs/pi/extensions.md +278 -0
- package/ai_docs/pi/how-pi-works.md +53 -0
- package/ai_docs/pi/index.md +43 -0
- package/ai_docs/pi/json.md +230 -0
- package/ai_docs/pi/keybindings.md +198 -0
- package/ai_docs/pi/llama-cpp.md +118 -0
- package/ai_docs/pi/mcp.md +274 -0
- package/ai_docs/pi/message-types.md +265 -0
- package/ai_docs/pi/models.md +209 -0
- package/ai_docs/pi/overview.md +166 -0
- package/ai_docs/pi/packages.md +133 -0
- package/ai_docs/pi/pi-agent-core.md +568 -0
- package/ai_docs/pi/pi-ai.md +1942 -0
- package/ai_docs/pi/prompt-templates.md +63 -0
- package/ai_docs/pi/providers.md +204 -0
- package/ai_docs/pi/quickstart.md +140 -0
- package/ai_docs/pi/readme.md +99 -0
- package/ai_docs/pi/rpc-commands.md +864 -0
- package/ai_docs/pi/rpc-extension-ui.md +204 -0
- package/ai_docs/pi/rpc.md +197 -0
- package/ai_docs/pi/sdk.md +151 -0
- package/ai_docs/pi/security.md +103 -0
- package/ai_docs/pi/session-format.md +297 -0
- package/ai_docs/pi/sessions.md +70 -0
- package/ai_docs/pi/settings.md +173 -0
- package/ai_docs/pi/shell-aliases.md +97 -0
- package/ai_docs/pi/skills.md +97 -0
- package/ai_docs/pi/slash-commands.md +64 -0
- package/ai_docs/pi/sources.md +57 -0
- package/ai_docs/pi/terminal-setup.md +223 -0
- package/ai_docs/pi/termux.md +121 -0
- package/ai_docs/pi/themes.md +140 -0
- package/ai_docs/pi/tmux.md +59 -0
- package/ai_docs/pi/toolkit.md +168 -0
- package/ai_docs/pi/tui.md +129 -0
- package/ai_docs/pi/usage.md +98 -0
- package/ai_docs/pi/virtual-models.md +118 -0
- package/ai_docs/pi/windows.md +71 -0
- package/apps/dpi-backend/.nvmrc +1 -0
- package/apps/dpi-backend/API.md +99 -0
- package/apps/dpi-backend/README.md +119 -0
- package/apps/dpi-backend/deploy/backend.env.example +9 -0
- package/apps/dpi-backend/deploy/dpi-backend.plist +26 -0
- package/apps/dpi-backend/deploy/dpi-backend.service +22 -0
- package/apps/dpi-backend/package.json +33 -0
- package/apps/dpi-backend/prompts/one_sentence.md +3 -0
- package/apps/dpi-backend/prompts/smart_ass_opus_fix.md +169 -0
- package/apps/dpi-backend/scripts/check-contract.mjs +151 -0
- package/apps/dpi-backend/scripts/migrate-playground.ts +66 -0
- package/apps/dpi-backend/scripts/record-nested-fixture.mts +154 -0
- package/apps/dpi-backend/scripts/token.ts +57 -0
- package/apps/dpi-backend/src/addons.ts +139 -0
- package/apps/dpi-backend/src/auth.ts +20 -0
- package/apps/dpi-backend/src/config.ts +54 -0
- package/apps/dpi-backend/src/contract-check.ts +118 -0
- package/apps/dpi-backend/src/errors.ts +50 -0
- package/apps/dpi-backend/src/extensions.ts +510 -0
- package/apps/dpi-backend/src/faux.ts +80 -0
- package/apps/dpi-backend/src/history.ts +165 -0
- package/apps/dpi-backend/src/idempotency.ts +20 -0
- package/apps/dpi-backend/src/jev.ts +43 -0
- package/apps/dpi-backend/src/jobs.ts +790 -0
- package/apps/dpi-backend/src/main.ts +76 -0
- package/apps/dpi-backend/src/model-catalog.ts +180 -0
- package/apps/dpi-backend/src/pairing-files.ts +142 -0
- package/apps/dpi-backend/src/pairing.ts +459 -0
- package/apps/dpi-backend/src/routes.ts +217 -0
- package/apps/dpi-backend/src/shared-state.ts +85 -0
- package/apps/dpi-backend/src/store.ts +1240 -0
- package/apps/dpi-backend/src/streams.ts +256 -0
- package/apps/dpi-backend/src/tunnel.ts +955 -0
- package/apps/dpi-backend/tsconfig.json +13 -0
- package/apps/dpi-playground/README.md +83 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +77 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/index.html +15 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +180 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/commands.ts +119 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/composables.ts +129 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/hub.ts +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/icon-path.ts +7 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/ids.ts +2 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/jobs.ts +80 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/link-policy.ts +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown-policy.ts +115 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-close.ts +23 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +166 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +323 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/signature.ts +146 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/store.ts +373 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/stream.ts +183 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +342 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Composer.vue +198 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ContextBar.vue +254 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceCredentialsDialog.vue +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceIcon.vue +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DirBrowser.vue +95 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ExtensionsPanel.vue +96 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/HelpDialog.vue +69 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +425 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/JobCard.vue +221 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/MobileNav.vue +39 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ModelPicker.vue +71 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NetworkSidebar.vue +236 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/OfflinePanel.vue +128 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +433 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PromptDialog.vue +89 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/StateEditor.vue +179 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TasksPanel.vue +280 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Toast.vue +11 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TokenManager.vue +175 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TopBar.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TranscriptItem.vue +152 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +195 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TunnelStatusBar.vue +107 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/extensions.css +105 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/jobs.css +655 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +22 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/network.css +675 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +3203 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/tsconfig.json +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/vite.config.ts +14 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +406 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/client-ip.ts +72 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +265 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/guard.ts +43 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/http-errors.ts +156 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/identity.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/limiter.ts +165 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +460 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/monitor.ts +320 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/mux.ts +277 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +816 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/proxy.ts +128 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/rotation.ts +583 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/seed.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/transport.ts +108 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-token.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-transport.ts +560 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +873 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/tsconfig.json +13 -0
- package/apps/dpi-playground/package.json +37 -0
- package/apps/dpi-sdk/README.md +74 -0
- package/apps/dpi-sdk/package.json +20 -0
- package/apps/dpi-sdk/scripts/net.ts +73 -0
- package/apps/dpi-sdk/src/api.ts +631 -0
- package/apps/dpi-sdk/src/app-state.ts +111 -0
- package/apps/dpi-sdk/src/client.ts +330 -0
- package/apps/dpi-sdk/src/errors.ts +16 -0
- package/apps/dpi-sdk/src/jobs.ts +99 -0
- package/apps/dpi-sdk/src/models.ts +472 -0
- package/apps/dpi-sdk/src/node/chunk.ts +488 -0
- package/apps/dpi-sdk/src/node/config.ts +175 -0
- package/apps/dpi-sdk/src/node/drivers.ts +402 -0
- package/apps/dpi-sdk/src/node/ops.ts +114 -0
- package/apps/dpi-sdk/src/node/private-dir.ts +48 -0
- package/apps/dpi-sdk/src/node/registry-migration.ts +549 -0
- package/apps/dpi-sdk/src/node/registry.ts +390 -0
- package/apps/dpi-sdk/src/node/sse-writer.ts +151 -0
- package/apps/dpi-sdk/src/protocol/backoff.ts +31 -0
- package/apps/dpi-sdk/src/protocol/limits.ts +199 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +311 -0
- package/apps/dpi-sdk/src/protocol/rest-routes.ts +105 -0
- package/apps/dpi-sdk/src/protocol/tunnel.ts +331 -0
- package/apps/dpi-sdk/src/sse.ts +117 -0
- package/apps/dpi-sdk/src/transcript.ts +291 -0
- package/apps/dpi-sdk/tsconfig.json +13 -0
- package/apps/dpi-sdk/w +20 -0
- package/apps/dpi-tui/README.md +54 -0
- package/apps/dpi-tui/bin/dpi-tui.mjs +5 -0
- package/apps/dpi-tui/package.json +20 -0
- package/apps/dpi-tui/src/app.ts +433 -0
- package/apps/dpi-tui/src/commands.ts +151 -0
- package/apps/dpi-tui/src/main.ts +122 -0
- package/apps/dpi-tui/src/pairing.ts +287 -0
- package/apps/dpi-tui/src/pickers.ts +238 -0
- package/apps/dpi-tui/src/render.ts +164 -0
- package/apps/dpi-tui/src/theme.ts +57 -0
- package/apps/dpi-tui/tsconfig.json +14 -0
- package/bin/device.mjs +5 -0
- package/bin/hub.mjs +5 -0
- package/bin/tui.mjs +5 -0
- package/deploy/Caddyfile +120 -0
- package/docs/verification.md +102 -0
- package/dpi-network.example.yaml +62 -0
- package/images/01_durable_agent_network.svg +185 -0
- package/images/02_pi_made_durable.svg +204 -0
- package/images/03_restart_mid_tool.svg +168 -0
- package/images/07_one_api_every_client.svg +123 -0
- package/images/08_device_session_agent.svg +123 -0
- package/images/09_compact_keep_history.svg +143 -0
- package/images/10_shared_app_state.svg +177 -0
- package/images/11_durable_jobs_wait.svg +141 -0
- package/images/12_tools_change_mid_run.svg +231 -0
- package/images/13_hook_blocks_tool.svg +122 -0
- package/images/14_fork_at_any_entry.svg +119 -0
- package/images/15_web_client.png +0 -0
- package/images/16_terminal_client.svg +43 -0
- package/images/17_devices_are_data.svg +182 -0
- package/images/18_should_every_agent_be_durable.svg +118 -0
- package/images/19_sqlite_on_every_device.svg +155 -0
- package/images/20_admission_and_effects.svg +134 -0
- package/justfile +33 -0
- package/package.json +41 -4
- package/scripts/verify.mjs +210 -0
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
// Durable jobs: repeatable workflows an agent (run_durable_job) or a person (Tasks tab) can start.
|
|
2
|
+
//
|
|
3
|
+
// Each job is one pi-durable task definition whose checkpoint phases are the job's named phases, so the task graph,
|
|
4
|
+
// the run record and the chat card all name the same phase. A phase does real work (shell commands in the session's
|
|
5
|
+
// folder, a subagent with read and bash, App State writes), records every step in the conversation document
|
|
6
|
+
// `demo.job-runs`, then commits the next checkpoint. A restart resumes the unfinished phase from its checkpoint.
|
|
7
|
+
import type { Context, JsonValue } from "@earendil-works/chord";
|
|
8
|
+
import {
|
|
9
|
+
configure,
|
|
10
|
+
defineDoc,
|
|
11
|
+
defineTask,
|
|
12
|
+
type ConversationId,
|
|
13
|
+
type RunningTask,
|
|
14
|
+
type TaskId,
|
|
15
|
+
type TaskRuntime,
|
|
16
|
+
type Tx,
|
|
17
|
+
} from "@earendil-works/pi-durable";
|
|
18
|
+
import { CodingTools, createBashTool, createReadTool } from "@earendil-works/pi-durable/tools";
|
|
19
|
+
import { readState } from "../../dpi-sdk/src/app-state.js";
|
|
20
|
+
import type { WireAssistantMessage, WireMessage, WireToolResultMessage } from "../../dpi-sdk/src/api.js";
|
|
21
|
+
import { messageText } from "../../dpi-sdk/src/transcript.js";
|
|
22
|
+
import {
|
|
23
|
+
JOB_RUNS_DOC,
|
|
24
|
+
type JobDef,
|
|
25
|
+
type JobInputValue,
|
|
26
|
+
type JobPhaseRun,
|
|
27
|
+
type JobRun,
|
|
28
|
+
type JobRunsDoc,
|
|
29
|
+
type JobStep,
|
|
30
|
+
type JobStepKind,
|
|
31
|
+
} from "../../dpi-sdk/src/jobs.js";
|
|
32
|
+
import type { SharedState } from "./shared-state.js";
|
|
33
|
+
|
|
34
|
+
export const JobRuns = defineDoc<JobRunsDoc>({
|
|
35
|
+
kind: JOB_RUNS_DOC,
|
|
36
|
+
version: 1,
|
|
37
|
+
scope: "conversation",
|
|
38
|
+
history: "latest",
|
|
39
|
+
fork: "initial",
|
|
40
|
+
initial: () => ({ counts: {}, items: {} }),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
type Values = Record<string, JobInputValue>;
|
|
44
|
+
type Data = Record<string, JsonValue>;
|
|
45
|
+
type JobInput = { job: string; input: Values };
|
|
46
|
+
type Checkpoint = { phase: string; iteration: number; data: Data; approvalTask?: number };
|
|
47
|
+
type JobResult = { summary: string };
|
|
48
|
+
type Run = TaskRuntime<JobInput, Checkpoint, JobResult, object>;
|
|
49
|
+
type Running = RunningTask<JobInput, Checkpoint, JobResult>;
|
|
50
|
+
|
|
51
|
+
/** Pause before each phase so a person can follow the run; durable, so a restart keeps the schedule. */
|
|
52
|
+
const PACE_MS = 900;
|
|
53
|
+
const MAX_RUNS = 30;
|
|
54
|
+
const MAX_STEPS = 40;
|
|
55
|
+
const RESULT_ROOT = "jobs";
|
|
56
|
+
|
|
57
|
+
const plain = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
|
58
|
+
const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
59
|
+
const tail = (s: string, lines = 8) => s.trim().split("\n").slice(-lines).join("\n");
|
|
60
|
+
const q = (s: string) => `'${s.replaceAll("'", `'\\''`)}'`;
|
|
61
|
+
|
|
62
|
+
// ------------------------------------------------------------------ definitions
|
|
63
|
+
|
|
64
|
+
export const JOB_DEFS: JobDef[] = [
|
|
65
|
+
{
|
|
66
|
+
id: "workspace-snapshot",
|
|
67
|
+
title: "Workspace snapshot",
|
|
68
|
+
summary: "Scan a folder, measure it and save a snapshot to App State. The same four phases every run.",
|
|
69
|
+
phases: [
|
|
70
|
+
{ id: "scan", label: "Scan", summary: "Read the git branch, changed files and top-level entries" },
|
|
71
|
+
{ id: "measure", label: "Measure", summary: "Count files by extension" },
|
|
72
|
+
{ id: "save", label: "Save", summary: "Write the snapshot to App State" },
|
|
73
|
+
{ id: "verify", label: "Verify", summary: "Read App State back and compare it with this run" },
|
|
74
|
+
],
|
|
75
|
+
inputs: [
|
|
76
|
+
{ name: "path", type: "string", default: ".", description: "Folder to snapshot, relative to the agent's working directory" },
|
|
77
|
+
],
|
|
78
|
+
approval: false,
|
|
79
|
+
resultPath: `/${RESULT_ROOT}/workspace-snapshot`,
|
|
80
|
+
seconds: 5,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "release-check",
|
|
84
|
+
title: "Release check",
|
|
85
|
+
summary:
|
|
86
|
+
"Plan a release review, let a subagent gather facts with read and bash, wait for your approval, then publish the notes to App State and verify them.",
|
|
87
|
+
phases: [
|
|
88
|
+
{ id: "plan", label: "Plan", summary: "Decide what the release notes must cover" },
|
|
89
|
+
{ id: "gather", label: "Gather", summary: "A subagent runs git and reads the README" },
|
|
90
|
+
{ id: "approve", label: "Approve", summary: "Wait for a person to approve publishing", kind: "approval" },
|
|
91
|
+
{ id: "publish", label: "Publish", summary: "Write the release notes to App State" },
|
|
92
|
+
{ id: "verify", label: "Verify", summary: "Read the notes back and confirm this run published them" },
|
|
93
|
+
],
|
|
94
|
+
inputs: [
|
|
95
|
+
{ name: "commits", type: "integer", default: 5, min: 1, max: 20, description: "How many recent commits the subagent reviews" },
|
|
96
|
+
],
|
|
97
|
+
approval: true,
|
|
98
|
+
resultPath: `/${RESULT_ROOT}/release-check`,
|
|
99
|
+
seconds: 20,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: "change-watch",
|
|
103
|
+
title: "Change watch",
|
|
104
|
+
summary:
|
|
105
|
+
"Take a baseline of a folder, check it again on a fixed interval with one checkpoint per check, then report what changed. Close the browser or restart the device mid-run; it resumes.",
|
|
106
|
+
phases: [
|
|
107
|
+
{ id: "baseline", label: "Baseline", summary: "Checksum every file up to two levels deep" },
|
|
108
|
+
{ id: "watch", label: "Watch", summary: "Re-check on an interval, one checkpoint per check", kind: "repeat" },
|
|
109
|
+
{ id: "report", label: "Report", summary: "Save what changed to App State" },
|
|
110
|
+
],
|
|
111
|
+
inputs: [
|
|
112
|
+
{ name: "path", type: "string", default: ".", description: "Folder to watch, relative to the agent's working directory" },
|
|
113
|
+
{ name: "checks", type: "integer", default: 5, min: 2, max: 20, description: "How many checks the watch phase runs" },
|
|
114
|
+
{ name: "interval_seconds", type: "integer", default: 4, min: 2, max: 60, description: "Seconds between checks" },
|
|
115
|
+
],
|
|
116
|
+
approval: false,
|
|
117
|
+
resultPath: `/${RESULT_ROOT}/change-watch`,
|
|
118
|
+
seconds: 25,
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
export const jobDef = (id: string) => JOB_DEFS.find((d) => d.id === id);
|
|
123
|
+
|
|
124
|
+
/** Validate a job's input against its spec, filling defaults. Throws a message the caller can show as is. */
|
|
125
|
+
export function resolveJobInput(def: JobDef, raw: unknown): Values {
|
|
126
|
+
const given = (raw ?? {}) as Record<string, unknown>;
|
|
127
|
+
if (typeof given !== "object" || Array.isArray(given)) throw Error("input must be an object");
|
|
128
|
+
const unknown = Object.keys(given).filter((k) => !def.inputs.some((i) => i.name === k));
|
|
129
|
+
if (unknown.length)
|
|
130
|
+
throw Error(`${def.id} has no input ${unknown.join(", ")}. Inputs: ${def.inputs.map((i) => i.name).join(", ") || "none"}`);
|
|
131
|
+
const out: Values = {};
|
|
132
|
+
for (const spec of def.inputs) {
|
|
133
|
+
const value = given[spec.name] ?? spec.default;
|
|
134
|
+
if (spec.type === "integer") {
|
|
135
|
+
const n = typeof value === "string" && value.trim() ? Number(value) : value;
|
|
136
|
+
if (typeof n !== "number" || !Number.isInteger(n)) throw Error(`${spec.name} must be an integer`);
|
|
137
|
+
if ((spec.min !== undefined && n < spec.min) || (spec.max !== undefined && n > spec.max))
|
|
138
|
+
throw Error(`${spec.name} must be ${spec.min} to ${spec.max}`);
|
|
139
|
+
out[spec.name] = n;
|
|
140
|
+
} else {
|
|
141
|
+
if (typeof value !== "string" || !value.trim() || value.includes("\0")) throw Error(`${spec.name} must be a non-empty string`);
|
|
142
|
+
out[spec.name] = value.trim();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ------------------------------------------------------------------ the phase API job handlers use
|
|
149
|
+
|
|
150
|
+
interface Phase {
|
|
151
|
+
readonly input: Values;
|
|
152
|
+
readonly data: Data;
|
|
153
|
+
readonly iteration: number;
|
|
154
|
+
readonly run: { id: number; n: number };
|
|
155
|
+
now(): number;
|
|
156
|
+
/** Run a shell command in the session's folder and record it as a step. Throws on a non-zero exit. */
|
|
157
|
+
exec(command: string, label?: string): Promise<string>;
|
|
158
|
+
/** Run a shell command without recording it. */
|
|
159
|
+
shell(command: string): Promise<{ code: number; out: string }>;
|
|
160
|
+
step<T>(kind: JobStepKind, label: string, fn: () => Promise<{ value: T; detail?: string }>): Promise<T>;
|
|
161
|
+
note(label: string, detail?: string): Promise<void>;
|
|
162
|
+
progress(done: number, total: number): Promise<void>;
|
|
163
|
+
saveState(path: string, value: JsonValue): Promise<void>;
|
|
164
|
+
readState(path: string): Promise<JsonValue | undefined>;
|
|
165
|
+
/** Run a subagent with read and bash in an owned child conversation; its tool calls become steps. */
|
|
166
|
+
agent(prompt: string): Promise<string>;
|
|
167
|
+
sleepUntil(at: number): Promise<void>;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
type Outcome = { done: string; data?: Data } | { again: Data; summary?: string };
|
|
171
|
+
type Handler = (p: Phase) => Promise<Outcome>;
|
|
172
|
+
|
|
173
|
+
const inDir = (dir: string, command: string) => (dir === "." ? command : `cd ${q(dir)} && ${command}`);
|
|
174
|
+
const label = (dir: string, command: string) => (dir === "." ? command : `${command} (in ${dir})`);
|
|
175
|
+
const FIND = `find . -maxdepth 2 -type f -not -path '*/.git/*' -not -path '*/node_modules/*'`;
|
|
176
|
+
|
|
177
|
+
const HANDLERS: Record<string, Record<string, Handler>> = {
|
|
178
|
+
"workspace-snapshot": {
|
|
179
|
+
scan: async (p) => {
|
|
180
|
+
const dir = String(p.input.path);
|
|
181
|
+
const branch = (
|
|
182
|
+
await p.exec(inDir(dir, `git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "(not a git repo)"`), label(dir, "git rev-parse --abbrev-ref HEAD"))
|
|
183
|
+
).trim();
|
|
184
|
+
const changed = (await p.exec(inDir(dir, "git status --short 2>/dev/null | head -50"), label(dir, "git status --short")))
|
|
185
|
+
.split("\n")
|
|
186
|
+
.filter(Boolean).length;
|
|
187
|
+
const entries = (await p.exec(inDir(dir, "ls -1A | head -200"), label(dir, "ls -1A"))).split("\n").filter(Boolean).length;
|
|
188
|
+
return { done: `${branch}, ${changed} changed, ${entries} entries`, data: { branch, changed, entries } };
|
|
189
|
+
},
|
|
190
|
+
measure: async (p) => {
|
|
191
|
+
const dir = String(p.input.path);
|
|
192
|
+
const count = Number(
|
|
193
|
+
(await p.exec(inDir(dir, `find . -type f -not -path '*/.git/*' -not -path '*/node_modules/*' | wc -l`), label(dir, "find . -type f | wc -l"))).trim(),
|
|
194
|
+
);
|
|
195
|
+
const raw = await p.exec(
|
|
196
|
+
inDir(
|
|
197
|
+
dir,
|
|
198
|
+
`find . -type f -not -path '*/.git/*' -not -path '*/node_modules/*' | sed -n 's/.*\\/[^/]*\\.\\([A-Za-z0-9]\\{1,8\\}\\)$/\\1/p' | sort | uniq -c | sort -rn | head -6`,
|
|
199
|
+
),
|
|
200
|
+
label(dir, "count files by extension"),
|
|
201
|
+
);
|
|
202
|
+
const top = raw
|
|
203
|
+
.split("\n")
|
|
204
|
+
.map((line) => line.trim().split(/\s+/))
|
|
205
|
+
.filter((parts) => parts.length === 2)
|
|
206
|
+
.map(([n, ext]) => ({ ext, count: Number(n) }));
|
|
207
|
+
return {
|
|
208
|
+
done: `${count} files${top.length ? `: ${top.map((t) => `${t.ext} ${t.count}`).join(", ")}` : ""}`,
|
|
209
|
+
data: { files: count, top },
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
save: async (p) => {
|
|
213
|
+
await p.saveState(`/${RESULT_ROOT}/workspace-snapshot`, {
|
|
214
|
+
run: p.run.n,
|
|
215
|
+
at: new Date(p.now()).toISOString(),
|
|
216
|
+
folder: String(p.input.path),
|
|
217
|
+
branch: p.data.branch ?? null,
|
|
218
|
+
changed: p.data.changed ?? 0,
|
|
219
|
+
entries: p.data.entries ?? 0,
|
|
220
|
+
files: p.data.files ?? 0,
|
|
221
|
+
top: p.data.top ?? [],
|
|
222
|
+
});
|
|
223
|
+
return { done: `Saved to App State /${RESULT_ROOT}/workspace-snapshot` };
|
|
224
|
+
},
|
|
225
|
+
verify: async (p) => {
|
|
226
|
+
const saved = (await p.readState(`/${RESULT_ROOT}/workspace-snapshot`)) as { run?: number; files?: number } | undefined;
|
|
227
|
+
await p.step("state", `read_state /${RESULT_ROOT}/workspace-snapshot`, async () => {
|
|
228
|
+
if (saved?.run !== p.run.n || saved.files !== p.data.files)
|
|
229
|
+
throw Error(`App State holds run #${saved?.run ?? "none"}, not run #${p.run.n}. Another writer changed it.`);
|
|
230
|
+
return { value: undefined, detail: `run #${saved.run}, ${saved.files} files` };
|
|
231
|
+
});
|
|
232
|
+
return { done: `App State matches run #${p.run.n}` };
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
"release-check": {
|
|
237
|
+
plan: async (p) => {
|
|
238
|
+
const checks = [`Last ${p.input.commits} commits`, "Uncommitted changes", "README headline"];
|
|
239
|
+
for (const [i, check] of checks.entries()) await p.note(`Check ${i + 1}: ${check}`);
|
|
240
|
+
return { done: `${checks.length} checks planned`, data: { checks } };
|
|
241
|
+
},
|
|
242
|
+
gather: async (p) => {
|
|
243
|
+
const notes = await p.agent(
|
|
244
|
+
`Release check: gather facts for run #${p.run.n}. Use bash to run \`git log --oneline -${p.input.commits}\` and \`git status --short\`, then read the first 30 lines of README.md if it exists. Reply with at most 5 short lines: what changed recently, what is uncommitted, and the README headline. Do not edit files.`,
|
|
245
|
+
);
|
|
246
|
+
return { done: clip(notes.split("\n").find((l) => l.trim()) ?? "Gathered", 120), data: { notes } };
|
|
247
|
+
},
|
|
248
|
+
approve: async () => {
|
|
249
|
+
throw Error("approval phases run in the engine");
|
|
250
|
+
},
|
|
251
|
+
publish: async (p) => {
|
|
252
|
+
await p.saveState(`/${RESULT_ROOT}/release-check`, {
|
|
253
|
+
run: p.run.n,
|
|
254
|
+
at: new Date(p.now()).toISOString(),
|
|
255
|
+
checks: p.data.checks ?? [],
|
|
256
|
+
notes: p.data.notes ?? "",
|
|
257
|
+
approved: true,
|
|
258
|
+
});
|
|
259
|
+
return { done: `Published to App State /${RESULT_ROOT}/release-check` };
|
|
260
|
+
},
|
|
261
|
+
verify: async (p) => {
|
|
262
|
+
const saved = (await p.readState(`/${RESULT_ROOT}/release-check`)) as { run?: number; notes?: string } | undefined;
|
|
263
|
+
await p.step("state", `read_state /${RESULT_ROOT}/release-check`, async () => {
|
|
264
|
+
if (saved?.run !== p.run.n) throw Error(`App State holds run #${saved?.run ?? "none"}, not run #${p.run.n}.`);
|
|
265
|
+
return { value: undefined, detail: `run #${saved.run}, ${saved.notes?.length ?? 0} characters of notes` };
|
|
266
|
+
});
|
|
267
|
+
return { done: `Release notes for run #${p.run.n} are live` };
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
"change-watch": {
|
|
272
|
+
baseline: async (p) => {
|
|
273
|
+
const dir = String(p.input.path);
|
|
274
|
+
const sums = await p.step("command", label(dir, "cksum every file (2 levels)"), async () => {
|
|
275
|
+
const r = await p.shell(inDir(dir, `${FIND} -exec cksum {} + 2>/dev/null | head -300`));
|
|
276
|
+
const map = checksums(r.out);
|
|
277
|
+
return { value: map, detail: `${Object.keys(map).length} files` };
|
|
278
|
+
});
|
|
279
|
+
return {
|
|
280
|
+
done: `Baseline of ${Object.keys(sums).length} files`,
|
|
281
|
+
data: { sums, changes: [], nextAt: p.now() + Number(p.input.interval_seconds) * 1000 },
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
watch: async (p) => {
|
|
285
|
+
const dir = String(p.input.path);
|
|
286
|
+
const total = Number(p.input.checks);
|
|
287
|
+
const n = p.iteration + 1;
|
|
288
|
+
await p.sleepUntil(Number(p.data.nextAt ?? 0));
|
|
289
|
+
const before = (p.data.sums ?? {}) as Record<string, string>;
|
|
290
|
+
const { sums, found } = await p.step("checkpoint", `Check ${n}/${total}`, async () => {
|
|
291
|
+
const r = await p.shell(inDir(dir, `${FIND} -exec cksum {} + 2>/dev/null | head -300`));
|
|
292
|
+
const now = checksums(r.out);
|
|
293
|
+
const diff = [
|
|
294
|
+
...Object.keys(now).filter((f) => !(f in before)).map((f) => `+ ${f}`),
|
|
295
|
+
...Object.keys(before).filter((f) => !(f in now)).map((f) => `- ${f}`),
|
|
296
|
+
...Object.keys(now).filter((f) => f in before && before[f] !== now[f]).map((f) => `~ ${f}`),
|
|
297
|
+
];
|
|
298
|
+
return {
|
|
299
|
+
value: { sums: now, found: diff },
|
|
300
|
+
detail: diff.length ? `${diff.length} change${diff.length === 1 ? "" : "s"}: ${clip(diff.join(", "), 200)}` : "No changes. Checkpoint saved.",
|
|
301
|
+
};
|
|
302
|
+
});
|
|
303
|
+
await p.progress(n, total);
|
|
304
|
+
const changes = [...((p.data.changes ?? []) as string[]), ...found].slice(-100);
|
|
305
|
+
if (n < total)
|
|
306
|
+
return { again: { sums, changes, nextAt: p.now() + Number(p.input.interval_seconds) * 1000 } };
|
|
307
|
+
return { done: `${total} checks, ${changes.length} change${changes.length === 1 ? "" : "s"}`, data: { sums: {}, changes } };
|
|
308
|
+
},
|
|
309
|
+
report: async (p) => {
|
|
310
|
+
const changes = (p.data.changes ?? []) as string[];
|
|
311
|
+
await p.saveState(`/${RESULT_ROOT}/change-watch`, {
|
|
312
|
+
run: p.run.n,
|
|
313
|
+
at: new Date(p.now()).toISOString(),
|
|
314
|
+
folder: String(p.input.path),
|
|
315
|
+
checks: Number(p.input.checks),
|
|
316
|
+
changes,
|
|
317
|
+
});
|
|
318
|
+
return { done: changes.length ? `${changes.length} changes saved to App State` : "No changes. Report saved to App State" };
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
function checksums(out: string) {
|
|
324
|
+
const map: Record<string, string> = {};
|
|
325
|
+
for (const line of out.split("\n")) {
|
|
326
|
+
const m = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
|
327
|
+
if (m) map[m[3].replace(/^\.\//, "")] = `${m[1]}:${m[2]}`;
|
|
328
|
+
}
|
|
329
|
+
return map;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ------------------------------------------------------------------ run record helpers
|
|
333
|
+
|
|
334
|
+
async function runOf(tx: Tx, task: { id: TaskId; conversationId: ConversationId }) {
|
|
335
|
+
const run = (await tx.doc(JobRuns, task.conversationId)).items[String(task.id)];
|
|
336
|
+
if (!run) throw Error(`Job run ${task.id} has no record`);
|
|
337
|
+
return run;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function upsertStep(phase: JobPhaseRun, step: JobStep) {
|
|
341
|
+
const found = phase.steps.find((s) => s.id === step.id);
|
|
342
|
+
if (found) Object.assign(found, step);
|
|
343
|
+
else {
|
|
344
|
+
phase.steps.push(step);
|
|
345
|
+
if (phase.steps.length > MAX_STEPS) phase.steps = plain(phase.steps.slice(-MAX_STEPS));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** End a run early: the current phase gets `status`, later phases are skipped. */
|
|
350
|
+
function endRun(run: JobRun, index: number, status: "failed" | "aborted", runStatus: JobRun["status"], message: string, now: number) {
|
|
351
|
+
run.status = runStatus;
|
|
352
|
+
run.error = message;
|
|
353
|
+
run.endedAt = now;
|
|
354
|
+
run.phases.forEach((phase, i) => {
|
|
355
|
+
if (i === index && phase.status !== "done") {
|
|
356
|
+
phase.status = status;
|
|
357
|
+
phase.endedAt = now;
|
|
358
|
+
phase.summary = message;
|
|
359
|
+
for (const step of phase.steps) if (step.status === "running" || step.status === "waiting") step.status = "error";
|
|
360
|
+
} else if (i > index || (i === index && phase.status === "pending")) phase.status = "skipped";
|
|
361
|
+
});
|
|
362
|
+
if (run.approval === "pending") run.approval = "cancelled";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ------------------------------------------------------------------ the engine
|
|
366
|
+
|
|
367
|
+
export interface JobsDeps {
|
|
368
|
+
shared: SharedState;
|
|
369
|
+
sessionId: string;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const CHILD_INSTRUCTIONS =
|
|
373
|
+
"You are one phase of a durable job. Use only the commands and files the task names. Never edit or write files. Answer in at most 5 short lines.";
|
|
374
|
+
|
|
375
|
+
export function createJobs(deps: JobsDeps) {
|
|
376
|
+
const Approval = defineTask<{ runId: number }, { phase: "wait" }, { approved: boolean }>({
|
|
377
|
+
name: "demo.job-approval",
|
|
378
|
+
version: 1,
|
|
379
|
+
initial: () => ({ phase: "wait" }),
|
|
380
|
+
phases: {
|
|
381
|
+
// Reads the run's decision every 500 ms without writing; the button click is the only write.
|
|
382
|
+
wait: async (task, runtime, ctx) => {
|
|
383
|
+
for (;;) {
|
|
384
|
+
const doc = await runtime.snapshot(JobRuns, task.conversationId, ctx);
|
|
385
|
+
const decision = doc?.items[String(task.input.runId)]?.approval;
|
|
386
|
+
if (decision && decision !== "pending") {
|
|
387
|
+
await runtime.commit(
|
|
388
|
+
() => ({ status: "terminal", outcome: { status: "completed", result: { approved: decision === "approved" } } }),
|
|
389
|
+
ctx,
|
|
390
|
+
);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
await runtime.sleep(runtime.now() + 500, ctx);
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
abort: async (_task, runtime, ctx) => {
|
|
398
|
+
await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), ctx);
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const phaseApi = (def: JobDef, index: number, task: Running, runtime: Run, ctx: Context, run: JobRun): Phase => {
|
|
403
|
+
const spec = def.phases[index];
|
|
404
|
+
const cp = task.state.checkpoint;
|
|
405
|
+
let counter = 0;
|
|
406
|
+
const record = (fn: (run: JobRun, phase: JobPhaseRun) => void) =>
|
|
407
|
+
runtime.commit(async (tx) => {
|
|
408
|
+
const current = await runOf(tx, task);
|
|
409
|
+
fn(current, current.phases[index]);
|
|
410
|
+
current.updatedAt = runtime.now();
|
|
411
|
+
return undefined;
|
|
412
|
+
}, ctx);
|
|
413
|
+
const shell = async (command: string) => {
|
|
414
|
+
const env = await runtime.env(ctx);
|
|
415
|
+
if (!env) throw Error("This session has no execution environment");
|
|
416
|
+
let out = "";
|
|
417
|
+
const result = await env.exec(command, { timeout: 60, onOutput: (text) => void (out.length < 50_000 && (out += text)) }, ctx);
|
|
418
|
+
if (!result.ok) throw Error(`${command}: ${result.error.message}`);
|
|
419
|
+
return { code: result.value.exitCode, out };
|
|
420
|
+
};
|
|
421
|
+
const step: Phase["step"] = async (kind, text, fn) => {
|
|
422
|
+
const id = `${spec.id}:${cp.iteration}:${counter++}`;
|
|
423
|
+
const at = runtime.now();
|
|
424
|
+
await record((_r, phase) => upsertStep(phase, { id, kind, label: text, status: "running", iteration: cp.iteration, at }));
|
|
425
|
+
try {
|
|
426
|
+
const { value, detail } = await fn();
|
|
427
|
+
await record((_r, phase) =>
|
|
428
|
+
upsertStep(phase, { id, kind, label: text, status: "done", iteration: cp.iteration, at, ms: runtime.now() - at, ...(detail ? { detail: clip(detail, 600) } : {}) }),
|
|
429
|
+
);
|
|
430
|
+
return value;
|
|
431
|
+
} catch (e) {
|
|
432
|
+
if (!runtime.signal.aborted)
|
|
433
|
+
await record((_r, phase) =>
|
|
434
|
+
upsertStep(phase, { id, kind, label: text, status: "error", iteration: cp.iteration, at, ms: runtime.now() - at, detail: clip(String((e as Error)?.message ?? e), 600) }),
|
|
435
|
+
);
|
|
436
|
+
throw e;
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
const stateKey = (path: string) => `job:${deps.sessionId}:${task.id}:${spec.id}:${cp.iteration}:${path}`;
|
|
440
|
+
return {
|
|
441
|
+
input: task.input.input,
|
|
442
|
+
data: cp.data,
|
|
443
|
+
iteration: cp.iteration,
|
|
444
|
+
run: { id: task.id as number, n: run.run },
|
|
445
|
+
now: () => runtime.now(),
|
|
446
|
+
shell,
|
|
447
|
+
step,
|
|
448
|
+
exec: (command, text) =>
|
|
449
|
+
step("command", text ?? command, async () => {
|
|
450
|
+
const r = await shell(command);
|
|
451
|
+
if (r.code !== 0) throw Error(`Exit ${r.code}: ${tail(r.out, 4)}`);
|
|
452
|
+
return { value: r.out, detail: tail(r.out) || "(no output)" };
|
|
453
|
+
}),
|
|
454
|
+
note: (text, detail) => step("note", text, async () => ({ value: undefined, detail })),
|
|
455
|
+
progress: (done, total) => record((_r, phase) => void (phase.progress = { done, total })),
|
|
456
|
+
saveState: (path, value) =>
|
|
457
|
+
step("state", `update_state ${path}`, async () => {
|
|
458
|
+
const [, top] = path.split("/");
|
|
459
|
+
const { value: root } = await deps.shared.snapshot();
|
|
460
|
+
const parent = root[top];
|
|
461
|
+
if (!parent || typeof parent !== "object" || Array.isArray(parent))
|
|
462
|
+
await deps.shared.change({ operation: parent === undefined ? "create" : "update", path: `/${top}`, value: {} }, `${stateKey(path)}:parent`);
|
|
463
|
+
const exists = (await deps.shared.snapshot()).value[top] as Record<string, unknown>;
|
|
464
|
+
const key = path.split("/").slice(2).join("/");
|
|
465
|
+
await deps.shared.change(
|
|
466
|
+
{ operation: Object.hasOwn(exists ?? {}, key) ? "update" : "create", path, value },
|
|
467
|
+
stateKey(path),
|
|
468
|
+
);
|
|
469
|
+
return { value: undefined, detail: clip(JSON.stringify(value), 300) };
|
|
470
|
+
}),
|
|
471
|
+
readState: async (path) => {
|
|
472
|
+
try {
|
|
473
|
+
return readState((await deps.shared.snapshot()).value, path);
|
|
474
|
+
} catch {
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
sleepUntil: (at) => runtime.sleep(at, ctx),
|
|
479
|
+
agent: async (prompt) => {
|
|
480
|
+
let childId = 0;
|
|
481
|
+
const stepId = `${spec.id}:${cp.iteration}:agent`;
|
|
482
|
+
const at = runtime.now();
|
|
483
|
+
await runtime.commit(async (tx) => {
|
|
484
|
+
const found = (await tx.scanConversations({ ownerTaskId: task.id }, 1)).items[0];
|
|
485
|
+
if (found) childId = found.id as number;
|
|
486
|
+
else {
|
|
487
|
+
const child = await tx.createConversation({ ownership: { kind: "task", taskId: task.id } });
|
|
488
|
+
await configure(tx, child.id, {
|
|
489
|
+
extensions: [CodingTools],
|
|
490
|
+
tools: [createReadTool(), createBashTool()],
|
|
491
|
+
instructions: CHILD_INSTRUCTIONS,
|
|
492
|
+
});
|
|
493
|
+
childId = child.id as number;
|
|
494
|
+
}
|
|
495
|
+
const current = await runOf(tx, task);
|
|
496
|
+
current.childConversationId = childId;
|
|
497
|
+
upsertStep(current.phases[index], {
|
|
498
|
+
id: stepId,
|
|
499
|
+
kind: "agent",
|
|
500
|
+
label: `Subagent #${childId}`,
|
|
501
|
+
detail: "Working with read and bash",
|
|
502
|
+
status: "running",
|
|
503
|
+
iteration: cp.iteration,
|
|
504
|
+
at,
|
|
505
|
+
conversationId: childId,
|
|
506
|
+
});
|
|
507
|
+
return undefined;
|
|
508
|
+
}, ctx);
|
|
509
|
+
const handle = await runtime.conversation(childId as ConversationId, ctx);
|
|
510
|
+
if (!handle) throw Error(`Subagent #${childId} is missing`);
|
|
511
|
+
const submission = await handle.submit(
|
|
512
|
+
{ type: "input", content: prompt, requestId: `job:${task.id}:${spec.id}` },
|
|
513
|
+
ctx,
|
|
514
|
+
);
|
|
515
|
+
const settled = submission.wait(ctx);
|
|
516
|
+
// Mirror the subagent's tool calls into the run record while it works; each call is its own pi.tool task.
|
|
517
|
+
let seen = "";
|
|
518
|
+
const sync = async () => {
|
|
519
|
+
const view = await runtime.context(childId as ConversationId, ctx);
|
|
520
|
+
const results = new Map<string, boolean>();
|
|
521
|
+
const calls: { id: string; name: string; args: unknown }[] = [];
|
|
522
|
+
for (const entry of view.entries) {
|
|
523
|
+
for (const m of (entry.model ?? []) as unknown as WireMessage[]) {
|
|
524
|
+
if (m.role === "toolResult") {
|
|
525
|
+
const r = m as WireToolResultMessage;
|
|
526
|
+
results.set(r.toolCallId, r.isError);
|
|
527
|
+
}
|
|
528
|
+
if (m.role === "assistant")
|
|
529
|
+
for (const b of (m as WireAssistantMessage).content)
|
|
530
|
+
if (b.type === "toolCall") calls.push({ id: b.id, name: b.name, args: b.arguments });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
const key = JSON.stringify(calls.map((c) => [c.id, results.get(c.id)]));
|
|
534
|
+
if (key === seen) return view;
|
|
535
|
+
seen = key;
|
|
536
|
+
await record((_r, phase) => {
|
|
537
|
+
for (const c of calls) {
|
|
538
|
+
const id = `${spec.id}:${cp.iteration}:tool:${c.id}`;
|
|
539
|
+
const prior = phase.steps.find((s) => s.id === id);
|
|
540
|
+
const status = !results.has(c.id) ? "running" : results.get(c.id) ? "error" : "done";
|
|
541
|
+
const started = prior?.at ?? runtime.now();
|
|
542
|
+
upsertStep(phase, {
|
|
543
|
+
id,
|
|
544
|
+
kind: "tool",
|
|
545
|
+
label: c.name,
|
|
546
|
+
detail: clip(summarizeArgs(c.args), 160),
|
|
547
|
+
status,
|
|
548
|
+
iteration: cp.iteration,
|
|
549
|
+
at: started,
|
|
550
|
+
conversationId: childId,
|
|
551
|
+
// Only a call seen running has a measured duration; one that finished between polls has none.
|
|
552
|
+
...(status !== "running" && prior ? { ms: prior.ms ?? runtime.now() - started } : {}),
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
return view;
|
|
557
|
+
};
|
|
558
|
+
let done: Awaited<typeof settled> | undefined;
|
|
559
|
+
while (!done) {
|
|
560
|
+
runtime.signal.throwIfAborted();
|
|
561
|
+
done = await Promise.race([
|
|
562
|
+
settled,
|
|
563
|
+
runtime.sleep(runtime.now() + 600, ctx).then(
|
|
564
|
+
() => undefined,
|
|
565
|
+
() => undefined,
|
|
566
|
+
),
|
|
567
|
+
]);
|
|
568
|
+
await sync();
|
|
569
|
+
}
|
|
570
|
+
const view = await sync();
|
|
571
|
+
const answer = done.status === "done" && done.type === "input" ? view.entries.find((e) => e.id === done.answer) : undefined;
|
|
572
|
+
const text = answer ? ((answer.model ?? []) as unknown as WireMessage[]).map(messageText).join("").trim() : "";
|
|
573
|
+
await record((_r, phase) =>
|
|
574
|
+
upsertStep(phase, {
|
|
575
|
+
id: stepId,
|
|
576
|
+
kind: "agent",
|
|
577
|
+
label: `Subagent #${childId}`,
|
|
578
|
+
detail: clip(text || `Ended: ${done.status}`, 600),
|
|
579
|
+
status: text ? "done" : "error",
|
|
580
|
+
iteration: cp.iteration,
|
|
581
|
+
at,
|
|
582
|
+
ms: runtime.now() - at,
|
|
583
|
+
conversationId: childId,
|
|
584
|
+
}),
|
|
585
|
+
);
|
|
586
|
+
if (!text) throw Error(`Subagent #${childId} returned no answer (${done.status})`);
|
|
587
|
+
return text;
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
/** Advance past phase `index`: the next phase's checkpoint, or the run's completed outcome. */
|
|
593
|
+
const finish = (def: JobDef, index: number, task: Running, runtime: Run, ctx: Context, summary: string, data: Data) =>
|
|
594
|
+
runtime.commit(async (tx) => {
|
|
595
|
+
const run = await runOf(tx, task);
|
|
596
|
+
const now = runtime.now();
|
|
597
|
+
const phase = run.phases[index];
|
|
598
|
+
phase.status = "done";
|
|
599
|
+
phase.endedAt = now;
|
|
600
|
+
phase.summary = summary;
|
|
601
|
+
run.updatedAt = now;
|
|
602
|
+
const next = def.phases[index + 1];
|
|
603
|
+
if (next) {
|
|
604
|
+
run.phase = next.id;
|
|
605
|
+
run.status = "running";
|
|
606
|
+
return { status: "running" as const, checkpoint: { phase: next.id, iteration: 0, data } };
|
|
607
|
+
}
|
|
608
|
+
run.status = "completed";
|
|
609
|
+
run.result = summary;
|
|
610
|
+
run.endedAt = now;
|
|
611
|
+
return { status: "terminal" as const, outcome: { status: "completed" as const, result: { summary } } };
|
|
612
|
+
}, ctx);
|
|
613
|
+
|
|
614
|
+
const fail = (index: number, task: Running, runtime: Run, ctx: Context, message: string, runStatus: "failed" | "declined") =>
|
|
615
|
+
runtime.commit(async (tx) => {
|
|
616
|
+
const run = await runOf(tx, task);
|
|
617
|
+
endRun(run, index, "failed", runStatus, message, runtime.now());
|
|
618
|
+
run.updatedAt = runtime.now();
|
|
619
|
+
return { status: "terminal" as const, outcome: { status: "failed" as const, error: { message } } };
|
|
620
|
+
}, ctx);
|
|
621
|
+
|
|
622
|
+
async function approvalPhase(def: JobDef, index: number, task: Running, runtime: Run, ctx: Context) {
|
|
623
|
+
const spec = def.phases[index];
|
|
624
|
+
const cp = task.state.checkpoint;
|
|
625
|
+
if (cp.approvalTask === undefined) {
|
|
626
|
+
await runtime.commit(async (tx) => {
|
|
627
|
+
const run = await runOf(tx, task);
|
|
628
|
+
const now = runtime.now();
|
|
629
|
+
const phase = run.phases[index];
|
|
630
|
+
phase.status = "waiting";
|
|
631
|
+
phase.startedAt ??= now;
|
|
632
|
+
run.status = "waiting";
|
|
633
|
+
run.phase = spec.id;
|
|
634
|
+
run.approval = "pending";
|
|
635
|
+
run.updatedAt = now;
|
|
636
|
+
upsertStep(phase, { id: `${spec.id}:0:decision`, kind: "approval", label: "Waiting for your decision", status: "waiting", iteration: 0, at: now });
|
|
637
|
+
const approvalTask = await tx.createTask(Approval, { runId: task.id as number }, { ownership: { kind: "task", taskId: task.id } });
|
|
638
|
+
return { status: "waiting", checkpoint: { ...cp, approvalTask: approvalTask as number }, on: [approvalTask], policy: "allSettled" };
|
|
639
|
+
}, ctx);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const [outcome] = await runtime.outcomes([cp.approvalTask as TaskId<{ approved: boolean }>], ctx);
|
|
643
|
+
const approved = outcome.status === "completed" && outcome.result.approved;
|
|
644
|
+
const decided = (status: JobStep["status"], text: string) =>
|
|
645
|
+
runtime.commit(async (tx) => {
|
|
646
|
+
const run = await runOf(tx, task);
|
|
647
|
+
const phase = run.phases[index];
|
|
648
|
+
const step = phase.steps.find((s) => s.id === `${spec.id}:0:decision`);
|
|
649
|
+
if (step) Object.assign(step, { status, label: text, ms: runtime.now() - step.at });
|
|
650
|
+
return undefined;
|
|
651
|
+
}, ctx);
|
|
652
|
+
if (approved) {
|
|
653
|
+
await decided("done", "Approved by you");
|
|
654
|
+
await finish(def, index, task, runtime, ctx, "Approved by you", cp.data);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
await decided("error", outcome.status === "completed" ? "Declined by you" : "Approval cancelled");
|
|
658
|
+
if (outcome.status === "completed") await fail(index, task, runtime, ctx, "Declined by you", "declined");
|
|
659
|
+
else
|
|
660
|
+
await runtime.commit(async (tx) => {
|
|
661
|
+
const run = await runOf(tx, task);
|
|
662
|
+
endRun(run, index, "aborted", "aborted", "Approval cancelled", runtime.now());
|
|
663
|
+
return { status: "terminal", outcome: { status: "aborted" } };
|
|
664
|
+
}, ctx);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function runPhase(def: JobDef, index: number, task: Running, runtime: Run, ctx: Context) {
|
|
668
|
+
const spec = def.phases[index];
|
|
669
|
+
const cp = task.state.checkpoint;
|
|
670
|
+
if (index > 0 && cp.iteration === 0 && cp.approvalTask === undefined) await runtime.sleep(runtime.now() + PACE_MS, ctx);
|
|
671
|
+
if (spec.kind === "approval") return approvalPhase(def, index, task, runtime, ctx);
|
|
672
|
+
|
|
673
|
+
// Enter the phase. Steps recorded by this same iteration mean a crash interrupted it: replace them.
|
|
674
|
+
let resumed = false;
|
|
675
|
+
let snapshot: JobRun | undefined;
|
|
676
|
+
await runtime.commit(async (tx) => {
|
|
677
|
+
const run = await runOf(tx, task);
|
|
678
|
+
const phase = run.phases[index];
|
|
679
|
+
const now = runtime.now();
|
|
680
|
+
if (phase.status === "pending") phase.startedAt = now;
|
|
681
|
+
else if (phase.steps.some((s) => s.iteration === cp.iteration)) {
|
|
682
|
+
phase.steps = plain(phase.steps.filter((s) => s.iteration !== cp.iteration));
|
|
683
|
+
run.resumed += 1;
|
|
684
|
+
resumed = true;
|
|
685
|
+
}
|
|
686
|
+
phase.status = "running";
|
|
687
|
+
run.status = "running";
|
|
688
|
+
run.phase = spec.id;
|
|
689
|
+
run.updatedAt = now;
|
|
690
|
+
snapshot = plain(run);
|
|
691
|
+
return undefined;
|
|
692
|
+
}, ctx);
|
|
693
|
+
const p = phaseApi(def, index, task, runtime, ctx, snapshot!);
|
|
694
|
+
if (resumed) await p.note("Resumed from the last checkpoint after a restart");
|
|
695
|
+
|
|
696
|
+
let outcome: Outcome;
|
|
697
|
+
try {
|
|
698
|
+
const handler = HANDLERS[def.id]?.[spec.id];
|
|
699
|
+
if (!handler) throw Error(`No handler for ${def.id}/${spec.id}`);
|
|
700
|
+
outcome = await handler(p);
|
|
701
|
+
} catch (e) {
|
|
702
|
+
if (runtime.signal.aborted) throw e;
|
|
703
|
+
await fail(index, task, runtime, ctx, clip(String((e as Error)?.message ?? e), 400), "failed");
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if ("again" in outcome) {
|
|
707
|
+
const data = outcome.again;
|
|
708
|
+
await runtime.commit(async (tx) => {
|
|
709
|
+
const run = await runOf(tx, task);
|
|
710
|
+
if (outcome.summary) run.phases[index].summary = outcome.summary;
|
|
711
|
+
run.updatedAt = runtime.now();
|
|
712
|
+
return { status: "running", checkpoint: { phase: spec.id, iteration: cp.iteration + 1, data } };
|
|
713
|
+
}, ctx);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
await finish(def, index, task, runtime, ctx, outcome.done, { ...cp.data, ...(outcome.data ?? {}) });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const tasks = JOB_DEFS.map((def) =>
|
|
720
|
+
defineTask<JobInput, Checkpoint, JobResult>({
|
|
721
|
+
name: `demo.job.${def.id}`,
|
|
722
|
+
version: 1,
|
|
723
|
+
initial: () => ({ phase: def.phases[0].id, iteration: 0, data: {} }),
|
|
724
|
+
phases: Object.fromEntries(
|
|
725
|
+
def.phases.map((spec, index) => [spec.id, (task: Running, runtime: Run, ctx: Context) => runPhase(def, index, task, runtime, ctx)]),
|
|
726
|
+
),
|
|
727
|
+
abort: async (task, runtime, ctx) => {
|
|
728
|
+
await runtime.commit(async (tx) => {
|
|
729
|
+
const run = await runOf(tx, task);
|
|
730
|
+
const index = Math.max(0, def.phases.findIndex((p) => p.id === task.state.checkpoint.phase));
|
|
731
|
+
endRun(run, index, "aborted", "aborted", "Stopped", runtime.now());
|
|
732
|
+
run.updatedAt = runtime.now();
|
|
733
|
+
return { status: "terminal", outcome: { status: "aborted" } };
|
|
734
|
+
}, ctx);
|
|
735
|
+
},
|
|
736
|
+
}),
|
|
737
|
+
);
|
|
738
|
+
const taskOf = (id: string) => tasks[JOB_DEFS.findIndex((d) => d.id === id)];
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Create a run inside a commit: the job's task and its record, together. `owner` makes the run part of that task's
|
|
742
|
+
* work (a run_durable_job call); without it the conversation owns the run in the background.
|
|
743
|
+
*/
|
|
744
|
+
async function start(
|
|
745
|
+
tx: Tx,
|
|
746
|
+
conversationId: ConversationId,
|
|
747
|
+
opts: { def: JobDef; input: Values; startedBy: "agent" | "you"; toolCallId?: string; owner?: TaskId; now: number },
|
|
748
|
+
) {
|
|
749
|
+
const background = opts.owner === undefined;
|
|
750
|
+
const taskId = await tx.createTask(
|
|
751
|
+
taskOf(opts.def.id),
|
|
752
|
+
{ job: opts.def.id, input: opts.input },
|
|
753
|
+
{ ownership: opts.owner === undefined ? { kind: "conversation" } : { kind: "task", taskId: opts.owner }, background },
|
|
754
|
+
);
|
|
755
|
+
const doc = await tx.doc(JobRuns, conversationId);
|
|
756
|
+
const n = (doc.counts[opts.def.id] ?? 0) + 1;
|
|
757
|
+
doc.counts[opts.def.id] = n;
|
|
758
|
+
doc.items[String(taskId)] = {
|
|
759
|
+
id: taskId as number,
|
|
760
|
+
job: opts.def.id,
|
|
761
|
+
title: opts.def.title,
|
|
762
|
+
run: n,
|
|
763
|
+
input: opts.input,
|
|
764
|
+
startedBy: opts.startedBy,
|
|
765
|
+
...(opts.toolCallId ? { toolCallId: opts.toolCallId } : {}),
|
|
766
|
+
background,
|
|
767
|
+
status: "running",
|
|
768
|
+
phase: opts.def.phases[0].id,
|
|
769
|
+
phases: opts.def.phases.map((p) => ({ id: p.id, label: p.label, status: "pending" as const, steps: [] })),
|
|
770
|
+
resumed: 0,
|
|
771
|
+
startedAt: opts.now,
|
|
772
|
+
updatedAt: opts.now,
|
|
773
|
+
};
|
|
774
|
+
const ids = Object.keys(doc.items).map(Number).sort((a, b) => a - b);
|
|
775
|
+
for (const old of ids.slice(0, Math.max(0, ids.length - MAX_RUNS))) delete doc.items[String(old)];
|
|
776
|
+
return taskId;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
return { defs: JOB_DEFS, tasks: [...tasks, Approval], start };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
export type Jobs = ReturnType<typeof createJobs>;
|
|
783
|
+
|
|
784
|
+
function summarizeArgs(args: unknown) {
|
|
785
|
+
if (!args || typeof args !== "object") return String(args ?? "");
|
|
786
|
+
const a = args as Record<string, unknown>;
|
|
787
|
+
if (typeof a.command === "string") return a.command;
|
|
788
|
+
if (typeof a.path === "string") return a.path;
|
|
789
|
+
return JSON.stringify(args);
|
|
790
|
+
}
|