Compare commits

...

6 Commits

Author SHA1 Message Date
xiaoju 30f1582046 fix: address review feedback on cursor agent PR
1. Replace manual OpenAI response parsing with createThreadReactor —
   workspace extraction now uses the same ReAct loop as extract/summarizer,
   with a zod schema and structured tool call
2. Remove non-null assertion on llmProvider, replaced with explicit guard
3. Add structured logging (LogFn) to extraction — failures, non-absolute
   paths, and successful extractions all logged with tag V3KM8QWP
4. export const run = wf is correct: createWorkflow returns WorkflowFn
   directly, old bundle-entry had stale .run access + unused 3rd arg

小橘 <xiaoju@shazhou.work>
2026-05-11 14:05:01 +00:00
xiaoju cf0540d7fa fix: stabilize kill-thread test by polling instead of fixed delay
Replace 50ms setTimeout with waitUntilPredicate polling for first role
completion before issuing kill. Same pattern used by pause/resume test.

小橘 <xiaoju@shazhou.work>
2026-05-11 13:56:14 +00:00
xiaoju c05fac746c feat: cursor agent auto-extracts workspace from context via LLM
- workflow-agent-cursor: workspace config now optional (string | null)
- When null, uses LLM call to extract workspace path from AgentContext
  (previous steps' meta, start prompt) before spawning cursor-agent CLI
- Requires llmProvider when workspace is null
- develop bundle-entry: switched from hermes to cursor agent for all roles
- solve-issue bundle-entry: created, developer role uses workflowAsAgent('develop'),
  preparer/submitter remain hermes

小橘 <xiaoju@shazhou.work>
2026-05-11 13:51:41 +00:00
xiaoju 34efd25e91 chore: split release into publish.sh + deploy.sh
- publish.sh: version bump → workspace:* swap → npm publish → restore → commit
- deploy.sh: build + deploy dashboard/gateway to Cloudflare (supports single target)

小橘 <xiaoju@shazhou.work>
2026-05-11 12:22:06 +00:00
xiaoju cc0bc6c8aa chore: add release.sh script
Automates: version bump → workspace:* replace → npm publish (topo order)
→ restore workspace:* → dashboard build+deploy → git commit+push.

Env: GITEA_TOKEN, CLOUDFLARE_API_TOKEN (from cfg).

小橘 <xiaoju@shazhou.work>
2026-05-11 12:21:07 +00:00
xiaoju 626cb5d98e Merge pull request 'fix: sort thread list newest-first and differentiate status colors' (#192) from fix/191-dashboard-thread-sort into main 2026-05-11 12:16:19 +00:00
11 changed files with 401 additions and 22 deletions
@@ -305,8 +305,13 @@ describe("cli thread commands", () => {
}
const threadId = ran.value.threadId;
const killBundleDir = getBundleDir(storageRoot, added.value.hash);
await new Promise((r) => setTimeout(r, 50));
await waitUntilPredicate(async () => {
const idx = await readThreadsIndex(killBundleDir);
const ent = idx[threadId];
return ent !== undefined && ent.head !== ent.start;
}, 80);
const killed = await cmdKill(storageRoot, threadId);
expect(killed.ok).toBe(true);
@@ -2,20 +2,32 @@ import { describe, expect, test } from "bun:test";
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
describe("validateCursorAgentConfig", () => {
test("accepts valid config", () => {
test("accepts valid config with explicit workspace", () => {
const r = validateCursorAgentConfig({
model: null,
timeout: 0,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(r.ok).toBe(true);
});
test("rejects non-function extract", () => {
test("accepts valid config with null workspace and llmProvider", () => {
const r = validateCursorAgentConfig({
model: null,
timeout: 0,
workspace: null,
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
});
expect(r.ok).toBe(true);
});
test("rejects empty workspace string", () => {
const r = validateCursorAgentConfig({
model: null,
timeout: 0,
workspace: "",
llmProvider: null,
});
expect(r.ok).toBe(false);
if (!r.ok) {
@@ -23,22 +35,47 @@ describe("validateCursorAgentConfig", () => {
}
});
test("rejects null workspace without llmProvider", () => {
const r = validateCursorAgentConfig({
model: null,
timeout: 0,
workspace: null,
llmProvider: null,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("llmProvider");
}
});
test("rejects negative timeout", () => {
const r = validateCursorAgentConfig({
model: null,
timeout: -1,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(r.ok).toBe(false);
});
});
describe("createCursorAgent", () => {
test("returns an AgentFn", () => {
test("returns an AgentFn with explicit workspace", () => {
const agent = createCursorAgent({
model: null,
timeout: 0,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(typeof agent).toBe("function");
});
test("returns an AgentFn with null workspace and llmProvider", () => {
const agent = createCursorAgent({
model: null,
timeout: 0,
workspace: null,
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
});
expect(typeof agent).toBe("function");
});
@@ -49,6 +86,18 @@ describe("createCursorAgent", () => {
model: null,
timeout: -1,
workspace: "/tmp/test-project",
llmProvider: null,
}),
).toThrow();
});
test("throws when null workspace without llmProvider", () => {
expect(() =>
createCursorAgent({
model: null,
timeout: 0,
workspace: null,
llmProvider: null,
}),
).toThrow();
});
@@ -8,7 +8,10 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-reactor": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*",
"zod": "^4.0.0"
}
@@ -0,0 +1,73 @@
import type { AgentContext, LlmProvider } from "@uncaged/workflow-protocol";
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import type { LogFn } from "@uncaged/workflow-util";
import * as z from "zod/v4";
const workspaceSchema = z.object({
workspace: z.string().describe("Absolute filesystem path of the project workspace"),
});
const EXTRACT_SYSTEM_FN = (_toolName: string) =>
`You are a workspace-path extractor. Given a workflow agent context (task description and previous step outputs), identify the absolute filesystem path of the project workspace where code changes should be made. Call the tool with the absolute path.`;
function buildExtractionInput(ctx: AgentContext): string {
const lines: string[] = [];
lines.push("## Task");
lines.push(ctx.start.content);
for (const step of ctx.steps) {
lines.push("");
lines.push(`## Step: ${step.role}`);
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
}
return lines.join("\n");
}
export async function extractWorkspacePath(
ctx: AgentContext,
provider: LlmProvider,
logger: LogFn,
): Promise<string | null> {
const reactor = createThreadReactor<null>({
llm: createLlmFn(provider),
maxRounds: 2,
staticTools: [],
structuredToolFromSchema: (schema) => {
const jsonSchema = z.toJSONSchema(schema);
return {
name: "set_workspace",
tool: {
type: "function" as const,
function: {
name: "set_workspace",
description: "Set the extracted workspace path",
parameters: jsonSchema as Record<string, unknown>,
},
},
};
},
systemPromptForStructuredTool: EXTRACT_SYSTEM_FN,
toolHandler: async () => "unknown tool",
});
const result = await reactor({
thread: null,
input: buildExtractionInput(ctx),
schema: workspaceSchema,
});
if (!result.ok) {
logger("V3KM8QWP", `workspace extraction failed: ${result.error}`);
return null;
}
const workspace = result.value.workspace.trim();
if (!workspace.startsWith("/")) {
logger("V3KM8QWP", `workspace extraction returned non-absolute path: ${workspace}`);
return null;
}
logger("V3KM8QWP", `extracted workspace: ${workspace}`);
return workspace;
}
+22 -2
View File
@@ -1,6 +1,8 @@
import type { AgentFn } from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import { extractWorkspacePath } from "./extract-workspace.js";
import type { CursorAgentConfig } from "./types.js";
import { validateCursorAgentConfig } from "./validate-config.js";
@@ -26,7 +28,7 @@ function resolveCursorModel(model: string | null): string {
return model === null ? "auto" : model;
}
/** Runs `cursor-agent` with workspace from {@link CursorAgentConfig.extract} and prompt from context. */
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
export function createCursorAgent(config: CursorAgentConfig): AgentFn {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
@@ -35,9 +37,27 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout > 0 ? config.timeout : null;
const logger = createLogger({ sink: { kind: "stderr" } });
return async (ctx) => {
const workspace = config.workspace;
let workspace: string;
if (config.workspace !== null) {
workspace = config.workspace;
} else {
if (config.llmProvider === null) {
throw new Error("cursor-agent: llmProvider is required when workspace is null");
}
const extracted = await extractWorkspacePath(ctx, config.llmProvider, logger);
if (extracted === null) {
throw new Error(
"cursor-agent: failed to extract workspace path from context. Provide an explicit workspace or ensure previous steps include a repoPath.",
);
}
workspace = extracted;
}
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
const fullPrompt = await buildAgentPrompt(ctx);
const args = [
"-p",
+6 -1
View File
@@ -1,5 +1,10 @@
import type { LlmProvider } from "@uncaged/workflow-protocol";
export type CursorAgentConfig = {
model: string | null;
timeout: number;
workspace: string;
/** Explicit workspace path. When `null`, the agent extracts workspace from AgentContext via a ReAct LLM call. */
workspace: string | null;
/** Required when `workspace` is `null` — LLM provider used for workspace extraction. */
llmProvider: LlmProvider | null;
};
@@ -1,10 +1,13 @@
import { err, ok, type Result } from "@uncaged/workflow-runtime";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import type { CursorAgentConfig } from "./types.js";
export function validateCursorAgentConfig(config: CursorAgentConfig): Result<void, string> {
if (typeof config.workspace !== "string" || config.workspace.length === 0) {
return err("workspace must be a non-empty string (absolute path)");
if (config.workspace !== null && config.workspace.length === 0) {
return err("workspace must be a non-empty string (absolute path) or null for auto-detection");
}
if (config.workspace === null && config.llmProvider === null) {
return err("llmProvider is required when workspace is null (needed for workspace extraction)");
}
if (config.timeout < 0) {
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
@@ -1,8 +1,9 @@
/**
* develop bundle entry — 小橘 🍊
*
* All roles use cursor-agent with workspace auto-extracted from context.
*/
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
import { createExtract } from "@uncaged/workflow-execute";
import { createCursorAgent } from "@uncaged/workflow-agent-cursor";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js";
@@ -22,23 +23,23 @@ function optionalEnv(name: string): string | null {
return value;
}
const provider = {
const llmProvider = {
baseUrl:
optionalEnv("WORKFLOW_LLM_BASE_URL") ?? "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: requireEnv("WORKFLOW_LLM_API_KEY"),
model: optionalEnv("WORKFLOW_LLM_MODEL") ?? "qwen-plus",
};
const agent = createHermesAgent({
model: optionalEnv("WORKFLOW_HERMES_MODEL"),
timeout: optionalEnv("WORKFLOW_HERMES_TIMEOUT")
? Number(optionalEnv("WORKFLOW_HERMES_TIMEOUT"))
: null,
const agent = createCursorAgent({
model: optionalEnv("WORKFLOW_CURSOR_MODEL"),
timeout: optionalEnv("WORKFLOW_CURSOR_TIMEOUT")
? Number(optionalEnv("WORKFLOW_CURSOR_TIMEOUT"))
: 0,
workspace: null,
llmProvider,
});
const extract = createExtract(provider);
const wf = createWorkflow(developWorkflowDefinition, { agent }, extract);
const wf = createWorkflow(developWorkflowDefinition, { agent, overrides: null });
export const descriptor = buildDevelopDescriptor();
export const run = wf.run;
export const run = wf;
@@ -0,0 +1,37 @@
/**
* solve-issue bundle entry — 小橘 🍊
*
* preparer + submitter → hermes agent
* developer → workflow-as-agent (delegates to "develop" workflow)
*/
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
import { workflowAsAgent } from "@uncaged/workflow-execute";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { buildSolveIssueDescriptor, solveIssueWorkflowDefinition } from "./src/index.js";
function optionalEnv(name: string): string | null {
const value = process.env[name];
if (value === undefined || value === "") {
return null;
}
return value;
}
const hermesAgent = createHermesAgent({
model: optionalEnv("WORKFLOW_HERMES_MODEL"),
timeout: optionalEnv("WORKFLOW_HERMES_TIMEOUT")
? Number(optionalEnv("WORKFLOW_HERMES_TIMEOUT"))
: null,
});
const developerAgent = workflowAsAgent("develop");
const wf = createWorkflow(solveIssueWorkflowDefinition, {
agent: hermesAgent,
overrides: {
developer: developerAgent,
},
});
export const descriptor = buildSolveIssueDescriptor();
export const run = wf;
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# deploy.sh — Build & deploy dashboard + gateway to Cloudflare
#
# Usage:
# ./scripts/deploy.sh # deploy both
# ./scripts/deploy.sh dashboard # dashboard only
# ./scripts/deploy.sh gateway # gateway only
#
# Env (via `cfg` or export):
# CLOUDFLARE_API_TOKEN — Cloudflare API token
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
CLOUDFLARE_API_TOKEN="${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required}"
export CLOUDFLARE_API_TOKEN
TARGET="${1:-all}"
deploy_dashboard() {
echo "🌐 Building dashboard..."
(cd packages/workflow-dashboard && npm run build)
echo "🚀 Deploying dashboard to Cloudflare Pages..."
(cd packages/workflow-gateway && npx wrangler pages deploy \
../workflow-dashboard/dist \
--project-name workflow-dashboard)
echo " ✅ Dashboard → workflow.shazhou.work"
}
deploy_gateway() {
echo "🚀 Deploying gateway Worker..."
(cd packages/workflow-gateway && npx wrangler deploy)
echo " ✅ Gateway → workflow-gateway.shazhou.workers.dev"
}
case "$TARGET" in
dashboard) deploy_dashboard ;;
gateway) deploy_gateway ;;
all) deploy_dashboard; deploy_gateway ;;
*) echo "Usage: deploy.sh [dashboard|gateway|all]"; exit 1 ;;
esac
echo "✅ Deploy complete"
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# publish.sh — Bump version & publish all @uncaged/workflow-* packages
#
# Usage:
# ./scripts/publish.sh 0.4.0 # explicit version
# ./scripts/publish.sh patch # 0.3.1 → 0.3.2
# ./scripts/publish.sh minor # 0.3.1 → 0.4.0
#
# Env (via `cfg` or export):
# GITEA_TOKEN — Gitea npm registry auth
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
GITEA_TOKEN="${GITEA_TOKEN:?GITEA_TOKEN is required}"
GITEA_NPM_REGISTRY="https://git.shazhou.work/api/packages/uncaged/npm/"
# ─── Version ─────────────────────────────────────────────────────────────────
current_version() {
node -e "console.log(require('./packages/workflow-protocol/package.json').version)"
}
bump_version() {
local cur="$1" kind="$2"
IFS='.' read -r major minor patch <<< "$cur"
case "$kind" in
patch) echo "${major}.${minor}.$((patch + 1))" ;;
minor) echo "${major}.$((minor + 1)).0" ;;
major) echo "$((major + 1)).0.0" ;;
*) echo "$kind" ;;
esac
}
CURRENT=$(current_version)
VERSION=$(bump_version "$CURRENT" "${1:?Usage: publish.sh <version|patch|minor|major>}")
echo "📦 Publish: $CURRENT$VERSION"
# ─── Topological publish order ───────────────────────────────────────────────
PUBLISH_ORDER=(
workflow-protocol
workflow-util
workflow-cas
workflow-runtime
workflow-reactor
workflow-register
workflow-execute
cli-workflow
workflow-util-agent
workflow-agent-cursor
workflow-agent-hermes
workflow-agent-llm
workflow-template-develop
workflow-template-solve-issue
)
# ─── Bump version ────────────────────────────────────────────────────────────
echo "🔢 Bumping versions..."
for dir in packages/*/; do
pkg="$dir/package.json"
[[ -f "$pkg" ]] || continue
is_private=$(node -e "console.log(require('./$pkg').private || false)")
[[ "$is_private" == "true" ]] && continue
node -e "
const fs = require('fs');
const p = JSON.parse(fs.readFileSync('$pkg','utf8'));
p.version = '$VERSION';
fs.writeFileSync('$pkg', JSON.stringify(p, null, 2) + '\n');
"
done
# ─── Replace workspace:* ─────────────────────────────────────────────────────
echo "🔗 Replacing workspace:* → $VERSION..."
for dir in packages/*/; do
pkg="$dir/package.json"
[[ -f "$pkg" ]] || continue
node -e "
const fs = require('fs');
const p = JSON.parse(fs.readFileSync('$pkg','utf8'));
let c = false;
for (const k of ['dependencies','peerDependencies','devDependencies']) {
if (!p[k]) continue;
for (const [n, v] of Object.entries(p[k])) {
if (n.startsWith('@uncaged/') && v === 'workspace:*') { p[k][n] = '$VERSION'; c = true; }
}
}
if (c) fs.writeFileSync('$pkg', JSON.stringify(p, null, 2) + '\n');
"
done
# ─── Build ───────────────────────────────────────────────────────────────────
echo "🔨 Building..."
npm run build
# ─── Publish ─────────────────────────────────────────────────────────────────
echo "🚀 Publishing..."
cat > "$REPO_ROOT/.npmrc" <<EOF
@uncaged:registry=${GITEA_NPM_REGISTRY}
//${GITEA_NPM_REGISTRY#https://}:_authToken=${GITEA_TOKEN}
EOF
FAIL=0
for pkg_dir in "${PUBLISH_ORDER[@]}"; do
if (cd "packages/$pkg_dir" && npm publish 2>&1); then
echo " ✅ @uncaged/$pkg_dir@$VERSION"
else
echo " ❌ @uncaged/$pkg_dir"
FAIL=1
fi
done
# ─── Restore workspace:* ─────────────────────────────────────────────────────
echo "🔄 Restoring workspace:*..."
for dir in packages/*/; do
pkg="$dir/package.json"
[[ -f "$pkg" ]] || continue
node -e "
const fs = require('fs');
const p = JSON.parse(fs.readFileSync('$pkg','utf8'));
let c = false;
for (const k of ['dependencies','peerDependencies','devDependencies']) {
if (!p[k]) continue;
for (const [n, v] of Object.entries(p[k])) {
if (n.startsWith('@uncaged/') && v === '$VERSION') { p[k][n] = 'workspace:*'; c = true; }
}
}
if (c) fs.writeFileSync('$pkg', JSON.stringify(p, null, 2) + '\n');
"
done
# ─── Commit ──────────────────────────────────────────────────────────────────
echo "📝 Committing..."
git add -A
git commit -m "chore: publish v${VERSION}
小橘 <xiaoju@shazhou.work>"
git push
[[ "$FAIL" -eq 0 ]] && echo "✅ v${VERSION} published" || echo "⚠️ v${VERSION} published with errors"