pi-feats 0.1.5 → 0.1.7
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 +48 -5
- package/extensions/api-server/README.md +21 -1
- package/extensions/api-server/application-runtime.ts +65 -4
- package/extensions/api-server/application-store.ts +5 -5
- package/extensions/api-server/index.ts +6 -2
- package/extensions/api-server/profile-store.ts +81 -34
- package/extensions/api-server/server.ts +179 -42
- package/extensions/cli-resources.ts +125 -94
- package/extensions/cli-tui-mode.ts +7 -0
- package/extensions/context-memory/index.ts +6 -1
- package/extensions/guardrails/index.ts +2 -1
- package/extensions/lib/application-context.ts +2 -0
- package/extensions/lib/cli-tui-mode.ts +72 -0
- package/extensions/lib/context-memory.ts +6 -1
- package/extensions/lib/package-resources.ts +58 -0
- package/extensions/lib/pi-command.ts +11 -0
- package/extensions/lib/profile-sandbox.ts +11 -3
- package/extensions/lib/remote-hosts.ts +7 -5
- package/extensions/lib/workflow-evaluator.ts +113 -0
- package/extensions/lib/workflow-harness.ts +409 -43
- package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
- package/extensions/pi-console-webui/app/globals.css +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
- package/extensions/pi-console-webui/components/application-sessions.tsx +2 -2
- package/extensions/pi-console-webui/components/application-settings.tsx +12 -6
- package/extensions/pi-console-webui/components/chat-workspace.tsx +17 -7
- package/extensions/pi-console-webui/components/console-page.tsx +1 -0
- package/extensions/pi-console-webui/components/console-state.tsx +1 -1
- package/extensions/pi-console-webui/components/console.tsx +26 -9
- package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
- package/extensions/pi-console-webui/components/profile-model-form.tsx +2 -2
- package/extensions/pi-console-webui/components/pulse-resources.tsx +4 -3
- package/extensions/pi-console-webui/components/sequential-workflow-config.tsx +32 -0
- package/extensions/pi-console-webui/components/skill-resources.tsx +4 -3
- package/extensions/pi-console-webui/components/ui/input.tsx +1 -1
- package/extensions/pi-console-webui/index.ts +2 -1
- package/extensions/pi-console-webui/lib/admin-models.ts +6 -4
- package/extensions/pi-console-webui/lib/page-metadata.ts +1 -0
- package/extensions/profile-ssh.ts +2 -2
- package/extensions/profiles.ts +17 -9
- package/extensions/pulse/index.ts +35 -17
- package/extensions/pulse/store.ts +29 -7
- package/extensions/sequential-workflow.ts +121 -79
- package/extensions/skill-sources/store.ts +29 -10
- package/package.json +19 -9
- package/scripts/build-console-webui.mjs +7 -0
- package/scripts/console-webui-cache.mjs +79 -0
- package/scripts/ensure-console-build.mjs +43 -0
- package/skills/sequential-workflow/SKILL.md +4 -3
- package/themes/legacy-dark.json +85 -0
|
@@ -7,9 +7,11 @@ export type PulseType = "cron" | "heartbeat";
|
|
|
7
7
|
export type Pulse = { id: string; name: string; description: string; type: PulseType; schedule: string; prompt: string; thread_session_id: string; insertIntoApiSession: boolean; result: string | null; profile: string; enabled: boolean; nextRunAt: string | null; lastRunAt: string | null };
|
|
8
8
|
export type PulseHistory = Pulse & { startedAt: string; finishedAt: string | null; status: string; error: string | null };
|
|
9
9
|
export type PulseRun = { id: string; sessionId: string | null; startedAt: string; finishedAt: string | null; status: "running" | "success" | "error"; response: string | null; error: string | null };
|
|
10
|
+
export type PulseExecutionSession = { sessionId: string; pulseId: string; pulseName: string; pulseType: PulseType; runId: string; status: PulseRun["status"]; startedAt: string; finishedAt: string | null };
|
|
10
11
|
export type ClaimedPulse = { pulse: Pulse; runId: string };
|
|
11
12
|
type Row = Record<string, unknown>;
|
|
12
13
|
const iso = (date = new Date()) => date.toISOString();
|
|
14
|
+
export const pulseId = (type: PulseType, date = new Date()): string => `pulse-${type}_${date.toISOString().slice(0, 16).replace("T", "-").replace(":", "-")}`;
|
|
13
15
|
|
|
14
16
|
function fieldMatches(field: string, value: number, min: number, max: number): boolean {
|
|
15
17
|
return field.split(",").some((part) => {
|
|
@@ -59,7 +61,7 @@ export class PulseStore {
|
|
|
59
61
|
private db: DatabaseSync;
|
|
60
62
|
constructor(path: string) {
|
|
61
63
|
mkdirSync(dirname(path), { recursive: true }); this.db = new DatabaseSync(path); this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;");
|
|
62
|
-
this.db.exec(`CREATE TABLE IF NOT EXISTS pulses (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT NOT NULL, type TEXT NOT NULL CHECK(type IN ('cron','heartbeat')), schedule TEXT NOT NULL, prompt TEXT NOT NULL, thread_session_id TEXT NOT NULL, insert_into_api_session INTEGER NOT NULL DEFAULT
|
|
64
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS pulses (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT NOT NULL, type TEXT NOT NULL CHECK(type IN ('cron','heartbeat')), schedule TEXT NOT NULL, prompt TEXT NOT NULL, thread_session_id TEXT NOT NULL, insert_into_api_session INTEGER NOT NULL DEFAULT 1, result TEXT);
|
|
63
65
|
CREATE TABLE IF NOT EXISTS pulse_control (pulse_id TEXT PRIMARY KEY REFERENCES pulses(id) ON DELETE CASCADE, profile TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, next_run_at TEXT, last_run_at TEXT, claimed_at TEXT, active_run_id TEXT, claim_owner TEXT, lease_expires_at TEXT, updated_at TEXT NOT NULL);
|
|
64
66
|
CREATE TABLE IF NOT EXISTS pulse_state (pulse_id TEXT PRIMARY KEY REFERENCES pulses(id) ON DELETE CASCADE, handoff TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL);
|
|
65
67
|
CREATE TABLE IF NOT EXISTS pulse_runs (id TEXT PRIMARY KEY, pulse_id TEXT NOT NULL REFERENCES pulses(id) ON DELETE CASCADE, session_id TEXT, started_at TEXT NOT NULL, finished_at TEXT, status TEXT NOT NULL, response TEXT, error TEXT);
|
|
@@ -68,7 +70,7 @@ export class PulseStore {
|
|
|
68
70
|
// Migrate databases created before the denormalized latest result column.
|
|
69
71
|
const columns = this.db.prepare("PRAGMA table_info(pulses)").all() as Row[];
|
|
70
72
|
if (!columns.some((column) => column.name === "result")) this.db.exec("ALTER TABLE pulses ADD COLUMN result TEXT");
|
|
71
|
-
if (!columns.some((column) => column.name === "insert_into_api_session")) this.db.exec("ALTER TABLE pulses ADD COLUMN insert_into_api_session INTEGER NOT NULL DEFAULT
|
|
73
|
+
if (!columns.some((column) => column.name === "insert_into_api_session")) this.db.exec("ALTER TABLE pulses ADD COLUMN insert_into_api_session INTEGER NOT NULL DEFAULT 1");
|
|
72
74
|
const runColumns = this.db.prepare("PRAGMA table_info(pulse_runs)").all() as Row[];
|
|
73
75
|
if (!runColumns.some((column) => column.name === "session_id")) this.db.exec("ALTER TABLE pulse_runs ADD COLUMN session_id TEXT");
|
|
74
76
|
const controlColumns = this.db.prepare("PRAGMA table_info(pulse_control)").all() as Row[];
|
|
@@ -93,12 +95,15 @@ export class PulseStore {
|
|
|
93
95
|
list(profile?: string): Pulse[] { const query = `SELECT p.*, c.profile, c.enabled, c.next_run_at, c.last_run_at FROM pulses p JOIN pulse_control c ON c.pulse_id=p.id${profile ? " WHERE c.profile=?" : ""} ORDER BY p.name`; return this.db.prepare(query).all(...(profile ? [profile] : [])) .map((row) => this.pulse(row as Row)); }
|
|
94
96
|
get(name: string, profile?: string): Pulse | undefined { return this.list(profile).find((item) => item.name === name); }
|
|
95
97
|
history(profile: string): PulseHistory[] { return this.db.prepare(`SELECT p.*, c.profile, c.enabled, c.next_run_at, c.last_run_at, r.started_at AS startedAt, r.finished_at AS finishedAt, r.status, r.error FROM pulses p JOIN pulse_control c ON c.pulse_id=p.id JOIN pulse_runs r ON r.id=(SELECT id FROM pulse_runs WHERE pulse_id=p.id AND finished_at IS NOT NULL ORDER BY started_at DESC LIMIT 1) WHERE c.profile=? AND p.schedule LIKE '@once:%' ORDER BY r.started_at DESC`).all(profile).map((row) => { const record = row as Row; return { ...this.pulse(record), startedAt: String(record.startedAt), finishedAt: record.finishedAt == null ? null : String(record.finishedAt), status: String(record.status), error: record.error == null ? null : String(record.error) }; }); }
|
|
98
|
+
executionSessions(profile: string): Map<string, PulseExecutionSession> { const rows = this.db.prepare("SELECT r.session_id,p.id AS pulse_id,p.name AS pulse_name,p.type AS pulse_type,r.id AS run_id,r.status,r.started_at,r.finished_at FROM pulse_runs r JOIN pulses p ON p.id=r.pulse_id JOIN pulse_control c ON c.pulse_id=p.id WHERE c.profile=? AND r.session_id IS NOT NULL ORDER BY r.started_at DESC").all(profile) as Row[]; return new Map(rows.map((row) => [String(row.session_id), { sessionId: String(row.session_id), pulseId: String(row.pulse_id), pulseName: String(row.pulse_name), pulseType: row.pulse_type as PulseType, runId: String(row.run_id), status: row.status as PulseRun["status"], startedAt: String(row.started_at), finishedAt: row.finished_at == null ? null : String(row.finished_at) }])); }
|
|
96
99
|
runs(profile: string, name: string, start: string, end: string, limit = 100): PulseRun[] { const pulse = this.get(name, profile); if (!pulse) throw new Error("Pulse not found."); return this.db.prepare("SELECT id, session_id, started_at, finished_at, status, response, error FROM pulse_runs WHERE pulse_id=? AND started_at>=? AND started_at<=? ORDER BY started_at DESC LIMIT ?").all(pulse.id, start, end, limit).map((row) => { const item = row as Row; return { id: String(item.id), sessionId: item.session_id == null ? null : String(item.session_id), startedAt: String(item.started_at), finishedAt: item.finished_at == null ? null : String(item.finished_at), status: item.status as PulseRun["status"], response: item.response == null ? null : String(item.response), error: item.error == null ? null : String(item.error) }; }); }
|
|
97
100
|
create(input: Omit<Pulse, "id" | "enabled" | "nextRunAt" | "lastRunAt" | "type" | "result" | "insertIntoApiSession"> & { insertIntoApiSession?: boolean }): Pulse {
|
|
98
101
|
if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(input.name)) throw new Error("Invalid pulse name.");
|
|
99
102
|
if (!validSchedule(input.schedule)) throw new Error("Invalid schedule. Use a five-field UTC cron expression or @once:<ISO-8601>.");
|
|
100
|
-
const type = pulseType(input.schedule),
|
|
101
|
-
this.db.prepare("
|
|
103
|
+
const type = pulseType(input.schedule), baseId = pulseId(type); let id = baseId, suffix = 2;
|
|
104
|
+
while (this.db.prepare("SELECT 1 FROM pulses WHERE id=?").get(id)) id = `${baseId}-${String(suffix++).padStart(2, "0")}`;
|
|
105
|
+
const next = nextRun(input.schedule)?.toISOString() ?? null;
|
|
106
|
+
this.db.prepare("INSERT INTO pulses (id, name, description, type, schedule, prompt, thread_session_id, insert_into_api_session, result) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)").run(id, input.name, input.description, type, input.schedule, input.prompt, input.thread_session_id, input.insertIntoApiSession !== false ? 1 : 0);
|
|
102
107
|
this.db.prepare("INSERT INTO pulse_control (pulse_id, profile, enabled, next_run_at, last_run_at, claimed_at, updated_at) VALUES (?, ?, 1, ?, NULL, NULL, ?)").run(id, input.profile, next, iso());
|
|
103
108
|
if (type === "heartbeat") this.db.prepare("INSERT INTO pulse_state VALUES (?, '', ?)").run(id, iso());
|
|
104
109
|
return this.get(input.name, input.profile)!;
|
|
@@ -109,13 +114,15 @@ export class PulseStore {
|
|
|
109
114
|
this.db.prepare("UPDATE pulse_control SET enabled=?, next_run_at=?, updated_at=? WHERE pulse_id=?").run(enabled ? 1 : 0, next, iso(), pulse.id);
|
|
110
115
|
return this.get(name, profile)!;
|
|
111
116
|
}
|
|
112
|
-
update(name: string, profile: string | undefined, input: Pick<Pulse, "description" | "schedule" | "prompt" | "thread_session_id" | "insertIntoApiSession">): Pulse {
|
|
117
|
+
update(name: string, profile: string | undefined, input: Pick<Pulse, "description" | "schedule" | "prompt" | "thread_session_id" | "insertIntoApiSession"> & { name?: string }): Pulse {
|
|
113
118
|
const pulse = this.get(name, profile); if (!pulse) throw new Error("Pulse not found.");
|
|
119
|
+
const nextName = input.name ?? pulse.name;
|
|
120
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(nextName)) throw new Error("Invalid pulse name.");
|
|
114
121
|
if (!validSchedule(input.schedule)) throw new Error("Invalid schedule. Use a five-field UTC cron expression or @once:<ISO-8601>.");
|
|
115
122
|
const type = pulseType(input.schedule);
|
|
116
|
-
this.db.prepare("UPDATE pulses SET description=?, type=?, schedule=?, prompt=?, thread_session_id=?, insert_into_api_session=? WHERE id=?").run(input.description, type, input.schedule, input.prompt, input.thread_session_id, input.insertIntoApiSession ? 1 : 0, pulse.id);
|
|
123
|
+
this.db.prepare("UPDATE pulses SET name=?, description=?, type=?, schedule=?, prompt=?, thread_session_id=?, insert_into_api_session=? WHERE id=?").run(nextName, input.description, type, input.schedule, input.prompt, input.thread_session_id, input.insertIntoApiSession ? 1 : 0, pulse.id);
|
|
117
124
|
this.db.prepare("UPDATE pulse_control SET next_run_at=?, updated_at=? WHERE pulse_id=?").run(pulse.enabled ? nextRun(input.schedule)?.toISOString() ?? null : null, iso(), pulse.id);
|
|
118
|
-
return this.get(
|
|
125
|
+
return this.get(nextName, profile)!;
|
|
119
126
|
}
|
|
120
127
|
delete(name: string, profile?: string): void { const pulse = this.get(name, profile); if (!pulse) throw new Error("Pulse not found."); this.db.prepare("DELETE FROM pulses WHERE id=?").run(pulse.id); }
|
|
121
128
|
deleteProfile(profile: string): void { this.db.prepare("DELETE FROM pulses WHERE id IN (SELECT pulse_id FROM pulse_control WHERE profile=?)").run(profile); }
|
|
@@ -184,6 +191,21 @@ export class PulseStore {
|
|
|
184
191
|
this.db.exec("COMMIT");
|
|
185
192
|
} catch (cause) { this.db.exec("ROLLBACK"); throw cause; }
|
|
186
193
|
}
|
|
194
|
+
finalizeRunning(name: string, profile: string, runId: string, status: "success" | "error"): PulseRun {
|
|
195
|
+
const pulse = this.get(name, profile); if (!pulse) throw new Error("Pulse not found.");
|
|
196
|
+
const now = iso(), result = status === "success" ? "Manually finalized as successful." : "Manually finalized as failed.", next = status === "success" ? nextRun(pulse.schedule)?.toISOString() ?? null : nextRun(pulse.schedule, new Date(Date.now() + 60_000))?.toISOString() ?? null;
|
|
197
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
198
|
+
try {
|
|
199
|
+
const run = this.db.prepare("UPDATE pulse_runs SET finished_at=?,status=?,response=?,error=? WHERE id=? AND pulse_id=? AND status='running'").run(now, status, status === "success" ? result : null, status === "error" ? result : null, runId, pulse.id);
|
|
200
|
+
if (!run.changes) throw new Error("Pulse run is not running.");
|
|
201
|
+
const control = this.db.prepare("UPDATE pulse_control SET last_run_at=?,next_run_at=?,claimed_at=NULL,active_run_id=NULL,claim_owner=NULL,lease_expires_at=NULL,updated_at=? WHERE pulse_id=? AND active_run_id=?").run(now, next, now, pulse.id, runId);
|
|
202
|
+
if (!control.changes) throw new Error("Pulse run is no longer active.");
|
|
203
|
+
if (status === "success") this.db.prepare("UPDATE pulses SET result=? WHERE id=?").run(result, pulse.id);
|
|
204
|
+
this.db.exec("COMMIT");
|
|
205
|
+
} catch (error) { this.db.exec("ROLLBACK"); throw error; }
|
|
206
|
+
const row = this.db.prepare("SELECT id,session_id,started_at,finished_at,status,response,error FROM pulse_runs WHERE id=?").get(runId) as Row;
|
|
207
|
+
return { id: String(row.id), sessionId: row.session_id == null ? null : String(row.session_id), startedAt: String(row.started_at), finishedAt: row.finished_at == null ? null : String(row.finished_at), status: row.status as PulseRun["status"], response: row.response == null ? null : String(row.response), error: row.error == null ? null : String(row.error) };
|
|
208
|
+
}
|
|
187
209
|
claimTickLease(owner: string, leaseMs = 90_000): boolean {
|
|
188
210
|
const now = iso(), expiresAt = new Date(Date.now() + leaseMs).toISOString();
|
|
189
211
|
this.db.exec("BEGIN IMMEDIATE");
|
|
@@ -9,7 +9,7 @@ import { isAbsolute, join, resolve } from "node:path";
|
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
10
|
type TaskType = "action" | "collect" | "evaluate" | "workflow";
|
|
11
11
|
type TaskStatus = "pending" | "running" | "awaiting_user" | "waiting_subworkflow" | "evaluating" | "accepted" | "rejected" | "failed";
|
|
12
|
-
type WorkflowStatus = "running" | "awaiting_user" | "evaluating" | "completed" | "cancelled" | "failed";
|
|
12
|
+
type WorkflowStatus = "pending_definition" | "running" | "awaiting_user" | "evaluating" | "completed" | "cancelled" | "failed";
|
|
13
13
|
type Task = {
|
|
14
14
|
id: number;
|
|
15
15
|
workflow_id: number;
|
|
@@ -48,43 +48,45 @@ const templateMaxTasks = 1000;
|
|
|
48
48
|
const templateMaxTitleLength = 1000;
|
|
49
49
|
const templateMaxSourceLength = 2000;
|
|
50
50
|
const templateMaxTextLength = 10000;
|
|
51
|
+
const maxActionAttempts = 5;
|
|
51
52
|
const taskType = StringEnum(["action", "collect", "evaluate", "workflow"] as const);
|
|
52
53
|
const phaseType = StringEnum(["action", "collect"] as const);
|
|
54
|
+
const evaluationOutcome = StringEnum(["accept", "retry", "fail"] as const);
|
|
53
55
|
const workflowRoot = () => process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
54
56
|
const workflowDatabase = () => join(workflowRoot(), "sequential-workflow.db");
|
|
55
57
|
const templateDirectory = () => join(workflowRoot(), "sequential_workflow_templates");
|
|
56
58
|
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
57
59
|
const requiredText = (value: unknown, field: string, maxLength: number) => {
|
|
58
60
|
if (typeof value !== "string" || value.trim().length === 0)
|
|
59
|
-
throw new Error(`
|
|
61
|
+
throw new Error(`Invalid template: ${field} is required.`);
|
|
60
62
|
if (value.length > maxLength)
|
|
61
|
-
throw new Error(`
|
|
63
|
+
throw new Error(`Invalid template: ${field} exceeds ${maxLength} characters.`);
|
|
62
64
|
return value;
|
|
63
65
|
};
|
|
64
66
|
const onlyProperties = (value: Record<string, unknown>, allowed: string[], field: string) => {
|
|
65
67
|
const unexpected = Object.keys(value).find((key) => !allowed.includes(key));
|
|
66
68
|
if (unexpected)
|
|
67
|
-
throw new Error(`
|
|
69
|
+
throw new Error(`Invalid template: ${field} contains unsupported property ${unexpected}.`);
|
|
68
70
|
};
|
|
69
71
|
const validateTemplate = (value: unknown): WorkflowTemplate => {
|
|
70
72
|
if (!isRecord(value))
|
|
71
|
-
throw new Error("
|
|
73
|
+
throw new Error("Invalid template: root must be a JSON object.");
|
|
72
74
|
onlyProperties(value, ["version", "title", "source", "tasks"], "raiz");
|
|
73
75
|
if (value.version !== 1)
|
|
74
|
-
throw new Error("
|
|
76
|
+
throw new Error("Invalid template: version must be 1.");
|
|
75
77
|
if (!Array.isArray(value.tasks) || value.tasks.length === 0)
|
|
76
|
-
throw new Error("
|
|
78
|
+
throw new Error("Invalid template: tasks must contain at least one task.");
|
|
77
79
|
if (value.tasks.length > templateMaxTasks)
|
|
78
|
-
throw new Error(`
|
|
80
|
+
throw new Error(`Invalid template: tasks cannot exceed ${templateMaxTasks} items.`);
|
|
79
81
|
const tasks = value.tasks.map((item, index) => {
|
|
80
82
|
if (!isRecord(item))
|
|
81
|
-
throw new Error(`
|
|
83
|
+
throw new Error(`Invalid template: tasks[${index}] must be an object.`);
|
|
82
84
|
onlyProperties(item, ["type", "instruction", "criteria"], `tasks[${index}]`);
|
|
83
85
|
if (item.type !== "action" && item.type !== "collect" && item.type !== "evaluate" && item.type !== "workflow")
|
|
84
|
-
throw new Error(`
|
|
86
|
+
throw new Error(`Invalid template: tasks[${index}].type must be action, collect, evaluate, or workflow.`);
|
|
85
87
|
const criteria = item.criteria === undefined ? undefined : requiredText(item.criteria, `tasks[${index}].criteria`, templateMaxTextLength);
|
|
86
88
|
if (item.type === "collect" && !criteria)
|
|
87
|
-
throw new Error(`
|
|
89
|
+
throw new Error(`Invalid template: tasks[${index}] is Collect and requires criteria.`);
|
|
88
90
|
return { type: item.type as TaskType, instruction: requiredText(item.instruction, `tasks[${index}].instruction`, templateMaxTextLength), criteria };
|
|
89
91
|
});
|
|
90
92
|
return { version: 1, title: requiredText(value.title, "title", templateMaxTitleLength), source: requiredText(value.source, "source", templateMaxSourceLength), tasks };
|
|
@@ -99,20 +101,20 @@ const loadTemplate = async (inputPath: string) => {
|
|
|
99
101
|
try {
|
|
100
102
|
const info = await stat(path);
|
|
101
103
|
if (!info.isFile())
|
|
102
|
-
throw new Error("
|
|
104
|
+
throw new Error("not a regular file");
|
|
103
105
|
if (info.size > templateMaxBytes)
|
|
104
106
|
throw new Error(`excede o limite de ${templateMaxBytes} bytes`);
|
|
105
107
|
content = await readFile(path, "utf8");
|
|
106
108
|
}
|
|
107
109
|
catch (error) {
|
|
108
|
-
throw new Error(`
|
|
110
|
+
throw new Error(`Could not read template ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
109
111
|
}
|
|
110
112
|
let document: unknown;
|
|
111
113
|
try {
|
|
112
114
|
document = JSON.parse(content);
|
|
113
115
|
}
|
|
114
116
|
catch (error) {
|
|
115
|
-
throw new Error(`
|
|
117
|
+
throw new Error(`Invalid template: malformed JSON in ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
116
118
|
}
|
|
117
119
|
return { path, hash: createHash("sha256").update(content).digest("hex"), template: validateTemplate(document) };
|
|
118
120
|
};
|
|
@@ -135,13 +137,14 @@ CREATE TABLE IF NOT EXISTS workflow_events (id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
|
135
137
|
const one = <T>(sql: string, ...params: SQLInputValue[]) => db.prepare(sql).get(...params) as T | undefined;
|
|
136
138
|
const many = <T>(sql: string, ...params: SQLInputValue[]) => db.prepare(sql).all(...params) as T[];
|
|
137
139
|
const event = (workflowId: number, taskId: number | null, phase: string, payload: unknown) => db.prepare("INSERT INTO workflow_events (workflow_id, task_id, phase, payload) VALUES (?, ?, ?, ?)").run(workflowId, taskId, phase, JSON.stringify(payload));
|
|
140
|
+
const evaluationRecord = (task: Task, outcome: "accept" | "retry" | "fail", reasoning: string, decision?: { evaluator?: string; model?: string; confidence?: number; probabilities?: Record<string, number> }) => JSON.stringify({ version: 1, outcome, reasoning, contract: { instruction: task.instruction, criteria: task.criteria }, evidence: { result: task.result }, decision, evaluatedAt: new Date().toISOString() });
|
|
138
141
|
const workflow = (id: number) => one<Workflow>("SELECT id, title, source, status, parent_workflow_id FROM workflows WHERE id = ?", id);
|
|
139
|
-
const isActive = (item: Workflow) => ["running", "awaiting_user", "evaluating"].includes(item.status);
|
|
142
|
+
const isActive = (item: Workflow) => ["pending_definition", "running", "awaiting_user", "evaluating"].includes(item.status);
|
|
140
143
|
const currentTask = (workflowId: number) => one<Task>("SELECT id, workflow_id, position, type, instruction, criteria, status, attempts, result, evaluation, child_workflow_id FROM workflow_tasks WHERE workflow_id = ? AND status NOT IN ('accepted', 'failed') ORDER BY position LIMIT 1", workflowId);
|
|
141
144
|
const taskSummary = (task: Task) => ({ id: task.id, position: task.position, type: task.type, instruction: task.instruction, criteria: task.criteria, status: task.status, attempts: task.attempts, childWorkflowId: task.child_workflow_id });
|
|
142
145
|
const requireActiveWorkflow = (id: number) => { const item = workflow(id); if (!item)
|
|
143
|
-
throw new Error(`Workflow #${id}
|
|
144
|
-
throw new Error(`Workflow #${id}
|
|
146
|
+
throw new Error(`Workflow #${id} does not exist.`); if (!isActive(item))
|
|
147
|
+
throw new Error(`Workflow #${id} is ${item.status} and cannot be changed.`); return item; };
|
|
145
148
|
const resolveParentAfterChild = (child: Workflow) => {
|
|
146
149
|
const parentTask = one<Task>("SELECT id, workflow_id, position, type, instruction, criteria, status, attempts, result, evaluation, child_workflow_id FROM workflow_tasks WHERE child_workflow_id = ? AND status = 'waiting_subworkflow'", child.id);
|
|
147
150
|
if (!parentTask)
|
|
@@ -149,7 +152,7 @@ CREATE TABLE IF NOT EXISTS workflow_events (id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
|
149
152
|
const parent = workflow(parentTask.workflow_id);
|
|
150
153
|
if (!parent || !isActive(parent))
|
|
151
154
|
return;
|
|
152
|
-
const result = `Subworkflow #${child.id} (${child.title})
|
|
155
|
+
const result = `Subworkflow #${child.id} (${child.title}) finished with status ${child.status}.`;
|
|
153
156
|
if (child.status === "completed" && !parentTask.criteria) {
|
|
154
157
|
db.prepare("UPDATE workflow_tasks SET result = ?, status = 'accepted', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(result, parentTask.id);
|
|
155
158
|
event(parent.id, parentTask.id, "subworkflow_completed", { childWorkflowId: child.id, accepted: true });
|
|
@@ -168,7 +171,7 @@ CREATE TABLE IF NOT EXISTS workflow_events (id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
|
168
171
|
if (!next) {
|
|
169
172
|
db.prepare("UPDATE workflows SET status = 'completed', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(item.id);
|
|
170
173
|
const completed = workflow(item.id)!;
|
|
171
|
-
event(item.id, null, "completed", { message: "
|
|
174
|
+
event(item.id, null, "completed", { message: "All tasks were accepted." });
|
|
172
175
|
resolveParentAfterChild(completed);
|
|
173
176
|
return { completed: true };
|
|
174
177
|
}
|
|
@@ -185,68 +188,87 @@ CREATE TABLE IF NOT EXISTS workflow_events (id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
|
185
188
|
db.prepare("UPDATE workflows SET status='cancelled', updated_at=CURRENT_TIMESTAMP WHERE id=?").run(item.id);
|
|
186
189
|
event(item.id, null, "cancelled", { by: "user", sessionId: harness.session() });
|
|
187
190
|
resolveParentAfterChild(workflow(item.id)!);
|
|
188
|
-
return { content: [{ type: "text" as const, text: `Workflow #${item.id}
|
|
191
|
+
return { content: [{ type: "text" as const, text: `Workflow #${item.id} cancelled.` }], details: { workflowId: item.id, status: "cancelled" } };
|
|
189
192
|
};
|
|
190
|
-
const
|
|
193
|
+
const validateDefinition = (definition: WorkflowDefinition) => {
|
|
191
194
|
for (const [index, task] of definition.tasks.entries())
|
|
192
195
|
if (task.type === "collect" && !task.criteria)
|
|
193
|
-
throw new Error(`Task ${index + 1}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
throw new Error(`Workflow pai #${parentWorkflowId} não existe.`);
|
|
212
|
-
{
|
|
213
|
-
const inserted = db.prepare("INSERT INTO workflows (title, source, status, parent_workflow_id, session_id) VALUES (?, ?, 'running', ?, ?)").run(definition.title, definition.source, parentWorkflowId ?? null, harness.session());
|
|
214
|
-
const workflowId = Number(inserted.lastInsertRowid);
|
|
215
|
-
const insertTask = db.prepare("INSERT INTO workflow_tasks (workflow_id, position, type, instruction, criteria, status) VALUES (?, ?, ?, ?, ?, 'pending')");
|
|
216
|
-
definition.tasks.forEach((task, index) => insertTask.run(workflowId, index + 1, task.type, task.instruction, task.criteria ?? null));
|
|
217
|
-
event(workflowId, null, "created", { title: definition.title, taskCount: definition.tasks.length, parentWorkflowId: parentWorkflowId ?? null });
|
|
218
|
-
if (parentTask) {
|
|
219
|
-
db.prepare("UPDATE workflow_tasks SET child_workflow_id = ?, status = 'waiting_subworkflow', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(workflowId, parentTask.id);
|
|
220
|
-
event(parentTask.workflow_id, parentTask.id, "child_linked", { childWorkflowId: workflowId });
|
|
221
|
-
}
|
|
222
|
-
const next = activateNext(workflow(workflowId)!);
|
|
223
|
-
const suffix = parentTask ? ` vinculado à task pai #${parentTask.id}.` : ".";
|
|
224
|
-
return { content: [{ type: "text" as const, text: next.completed ? `Workflow #${workflowId} criado e concluído${suffix}` : `Workflow #${workflowId} criado${suffix} Execute somente a task #${next.task!.position}: ${next.task!.instruction}` }], details: { workflowId, parentWorkflowId: parentWorkflowId ?? null, parentTaskId: parentTask?.id ?? null, next } };
|
|
196
|
+
throw new Error(`Task ${index + 1} is Collect and requires criteria.`);
|
|
197
|
+
};
|
|
198
|
+
const insertTasks = (workflowId: number, definition: WorkflowDefinition) => {
|
|
199
|
+
const insertTask = db.prepare("INSERT INTO workflow_tasks (workflow_id, position, type, instruction, criteria, status) VALUES (?, ?, ?, ?, ?, 'pending')");
|
|
200
|
+
definition.tasks.forEach((task, index) => insertTask.run(workflowId, index + 1, task.type, task.instruction, task.criteria ?? null));
|
|
201
|
+
};
|
|
202
|
+
const workflowResult = (workflowId: number, parentTask?: Task) => {
|
|
203
|
+
const next = activateNext(workflow(workflowId)!);
|
|
204
|
+
const suffix = parentTask ? ` linked to parent task #${parentTask.id}.` : ".";
|
|
205
|
+
return { content: [{ type: "text" as const, text: next.completed ? `Workflow #${workflowId} created and completed${suffix}` : `Workflow #${workflowId} created${suffix} Execute only task #${next.task!.position}: ${next.task!.instruction}` }], details: { workflowId, parentWorkflowId: parentTask?.workflow_id ?? null, parentTaskId: parentTask?.id ?? null, next } };
|
|
206
|
+
};
|
|
207
|
+
const createRootWorkflow = (definition: WorkflowDefinition) => {
|
|
208
|
+
validateDefinition(definition);
|
|
209
|
+
const pendingWorkflowId = harness.pendingWorkflow();
|
|
210
|
+
const workflowId = pendingWorkflowId ?? Number(db.prepare("INSERT INTO workflows (title, source, status, session_id) VALUES (?, ?, 'running', ?)").run(definition.title, definition.source, harness.session()).lastInsertRowid);
|
|
211
|
+
if (pendingWorkflowId) {
|
|
212
|
+
db.prepare("UPDATE workflows SET title=?, source=?, status='running', updated_at=CURRENT_TIMESTAMP WHERE id=?").run(definition.title, definition.source, workflowId);
|
|
213
|
+
event(workflowId, null, "definition_supplied", { title: definition.title, taskCount: definition.tasks.length });
|
|
225
214
|
}
|
|
215
|
+
else
|
|
216
|
+
event(workflowId, null, "created", { title: definition.title, taskCount: definition.tasks.length, parentWorkflowId: null });
|
|
217
|
+
insertTasks(workflowId, definition);
|
|
218
|
+
return workflowResult(workflowId);
|
|
219
|
+
};
|
|
220
|
+
const createSubworkflow = (definition: WorkflowDefinition, parentWorkflowId: number, parentTaskId: number) => {
|
|
221
|
+
validateDefinition(definition);
|
|
222
|
+
const parentTask = one<Task>("SELECT id, workflow_id, position, type, instruction, criteria, status, attempts, result, evaluation, child_workflow_id FROM workflow_tasks WHERE id = ?", parentTaskId);
|
|
223
|
+
if (!parentTask)
|
|
224
|
+
throw new Error(`Parent task #${parentTaskId} does not exist.`);
|
|
225
|
+
if (parentTask.workflow_id !== parentWorkflowId)
|
|
226
|
+
throw new Error("parentWorkflowId does not match parentTaskId's workflow.");
|
|
227
|
+
if (parentTask.type !== "workflow")
|
|
228
|
+
throw new Error(`Task pai #${parentTaskId} deve ser do tipo workflow.`);
|
|
229
|
+
if (parentTask.status !== "running")
|
|
230
|
+
throw new Error(`Parent task #${parentTaskId} is not ready to start a subworkflow.`);
|
|
231
|
+
if (parentTask.child_workflow_id !== null)
|
|
232
|
+
throw new Error(`Parent task #${parentTaskId} already has a linked subworkflow.`);
|
|
233
|
+
requireActiveWorkflow(parentWorkflowId);
|
|
234
|
+
const workflowId = Number(db.prepare("INSERT INTO workflows (title, source, status, parent_workflow_id, session_id) VALUES (?, ?, 'running', ?, ?)").run(definition.title, definition.source, parentWorkflowId, harness.session()).lastInsertRowid);
|
|
235
|
+
event(workflowId, null, "created", { title: definition.title, taskCount: definition.tasks.length, parentWorkflowId });
|
|
236
|
+
insertTasks(workflowId, definition);
|
|
237
|
+
db.prepare("UPDATE workflow_tasks SET child_workflow_id = ?, status = 'waiting_subworkflow', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(workflowId, parentTask.id);
|
|
238
|
+
event(parentTask.workflow_id, parentTask.id, "child_linked", { childWorkflowId: workflowId });
|
|
239
|
+
return workflowResult(workflowId, parentTask);
|
|
226
240
|
};
|
|
227
|
-
|
|
241
|
+
const definitionParameters = { title: Type.String({ minLength: 1 }), source: Type.String({ minLength: 1 }), tasks: Type.Array(Type.Object({ type: taskType, instruction: Type.String({ minLength: 1 }), criteria: Type.Optional(Type.String({ minLength: 1 })) }), { minItems: 1 }) };
|
|
242
|
+
pi.registerTool({ name: "sequential_workflow_create", label: "Create Sequential Workflow", description: "Creates a root workflow or defines the focused pending workflow.", promptSnippet: "Create or define a root Sequential Workflow", promptGuidelines: ["Use sequential_workflow_create only after an explicit Sequential Workflow requirement was identified in the current user input or a Skill. It creates a root workflow or defines the focused pending workflow; it never creates a subworkflow.", "Do not use a template unless the user explicitly requested a named template; use sequential_workflow_create_from_template only for that case.", "Generate every workflow artifact in English: title, source, instructions, criteria, and recorded result summaries.", "A Collect task passed to sequential_workflow_create must include strict acceptance criteria. Criteria are a durable acceptance contract: preserve every material collection requirement in concrete, verifiable terms. Do not weaken a compound requirement to non-empty input; for example, collecting a full name must require both given name and surname.", "Action criteria must be outcome-oriented and proportionate: verify completion and recorded evidence of the requested action, without requiring an evaluator to independently reconstruct incidental calculations unless the request explicitly makes that verification critical.", "An Action task with criteria fails its workflow after five rejected attempts."], parameters: Type.Object(definitionParameters), async execute(_id, params): Promise<{
|
|
228
243
|
content: {
|
|
229
244
|
type: "text";
|
|
230
245
|
text: string;
|
|
231
246
|
}[];
|
|
232
247
|
details: Record<string, unknown>;
|
|
233
|
-
}> { return
|
|
234
|
-
pi.registerTool({ name: "
|
|
235
|
-
pi.registerTool({ name: "sequential_workflow_validate_template", label: "Validate Sequential Workflow Template", description: "Lê e valida um arquivo JSON de template de Sequential Workflow sem criar ou executar um workflow.", promptSnippet: "Validate a Sequential Workflow JSON template before it is used", promptGuidelines: ["Use sequential_workflow_validate_template only when the user explicitly names Sequential Workflow and asks to create, edit, or validate its JSON template."], parameters: Type.Object({ path: Type.String({ minLength: 1 }) }), async execute(_id, params): Promise<{
|
|
248
|
+
}> { return createRootWorkflow(params); } });
|
|
249
|
+
pi.registerTool({ name: "sequential_workflow_create_subworkflow", label: "Create Sequential Subworkflow", description: "Creates a child workflow for the focused running workflow task.", promptSnippet: "Create a Sequential Workflow child", promptGuidelines: ["Use only when the focused current task has type workflow and requires a child workflow.", "parentWorkflowId and parentTaskId are required and must identify that focused running task."], parameters: Type.Object({ parentWorkflowId: Type.Integer({ minimum: 1 }), parentTaskId: Type.Integer({ minimum: 1 }), ...definitionParameters }), async execute(_id, params): Promise<{
|
|
236
250
|
content: {
|
|
237
251
|
type: "text";
|
|
238
252
|
text: string;
|
|
239
253
|
}[];
|
|
240
254
|
details: Record<string, unknown>;
|
|
241
|
-
}> {
|
|
242
|
-
pi.registerTool({ name: "
|
|
255
|
+
}> { return createSubworkflow(params, params.parentWorkflowId, params.parentTaskId); } });
|
|
256
|
+
pi.registerTool({ name: "sequential_workflow_prepare_template_directory", label: "Prepare Sequential Workflow Template Directory", description: "Creates when needed and reports the active profile's default Sequential Workflow JSON template directory.", promptSnippet: "Prepare the active profile's default Sequential Workflow template directory", promptGuidelines: ["Use sequential_workflow_prepare_template_directory only when the user explicitly names Sequential Workflow and asks to create or edit its JSON template without specifying an output directory."], parameters: Type.Object({}), async execute() { const path = templateDirectory(); await mkdir(path, { recursive: true }); return { content: [{ type: "text", text: `Template directory is ready: ${path}` }], details: { path } }; } });
|
|
257
|
+
pi.registerTool({ name: "sequential_workflow_validate_template", label: "Validate Sequential Workflow Template", description: "Reads and validates a Sequential Workflow JSON template without creating or executing a workflow.", promptSnippet: "Validate a Sequential Workflow JSON template before it is used", promptGuidelines: ["Use sequential_workflow_validate_template only when the user explicitly names Sequential Workflow and asks to create, edit, or validate its JSON template."], parameters: Type.Object({ path: Type.String({ minLength: 1 }) }), async execute(_id, params): Promise<{
|
|
243
258
|
content: {
|
|
244
259
|
type: "text";
|
|
245
260
|
text: string;
|
|
246
261
|
}[];
|
|
247
262
|
details: Record<string, unknown>;
|
|
248
|
-
}> { const loaded = await loadTemplate(params.path); return
|
|
249
|
-
pi.registerTool({ name: "
|
|
263
|
+
}> { const loaded = await loadTemplate(params.path); return { content: [{ type: "text", text: `Valid template: ${loaded.path} (${loaded.template.tasks.length} tasks, SHA-256: ${loaded.hash}).` }], details: loaded }; } });
|
|
264
|
+
pi.registerTool({ name: "sequential_workflow_create_from_template", label: "Create Sequential Workflow from Template", description: "Loads an explicitly named reusable template to create a root workflow or hydrate a template-mode pending workflow.", promptSnippet: "Create a root Sequential Workflow from an explicitly named template", promptGuidelines: ["Use sequential_workflow_create_from_template only when the user explicitly requested activation from a named reusable Sequential Workflow JSON template, or when template-mode definition context supplies that exact path.", "The path must be explicitly supplied by the user or the controlled template-mode context. Never guess, search for, infer, or invent a template name or use this tool as a fallback for an ordinary workflow request.", "This tool never creates a subworkflow."], parameters: Type.Object({ path: Type.String({ minLength: 1 }) }), async execute(_id, params): Promise<{
|
|
265
|
+
content: {
|
|
266
|
+
type: "text";
|
|
267
|
+
text: string;
|
|
268
|
+
}[];
|
|
269
|
+
details: Record<string, unknown>;
|
|
270
|
+
}> { const loaded = await loadTemplate(params.path); return createRootWorkflow({ title: loaded.template.title, source: `Template ${loaded.path} (SHA-256: ${loaded.hash}): ${loaded.template.source}`, tasks: loaded.template.tasks }); } });
|
|
271
|
+
pi.registerTool({ name: "sequential_workflow_record_result", label: "Record Workflow Result", description: "Records the result of the current Action or Collect task in an identified workflow.", promptSnippet: "Record the result of the active Action or Collect task", promptGuidelines: ["Use sequential_workflow_record_result immediately after completing the active Action or receiving the active Collect response."], parameters: Type.Object({ workflowId: Type.Integer({ minimum: 1 }), taskId: Type.Integer({ minimum: 1 }), phase: phaseType, result: Type.String({ minLength: 1 }) }), async execute(_id, params): Promise<{
|
|
250
272
|
content: {
|
|
251
273
|
type: "text";
|
|
252
274
|
text: string;
|
|
@@ -256,23 +278,23 @@ CREATE TABLE IF NOT EXISTS workflow_events (id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
|
256
278
|
const item = requireActiveWorkflow(params.workflowId);
|
|
257
279
|
const task = currentTask(item.id);
|
|
258
280
|
if (!task)
|
|
259
|
-
throw new Error("
|
|
281
|
+
throw new Error("There is no pending task.");
|
|
260
282
|
if (task.type !== params.phase)
|
|
261
|
-
throw new Error(`
|
|
283
|
+
throw new Error(`The current task is ${task.type}, not ${params.phase}.`);
|
|
262
284
|
if (task.status === "evaluating")
|
|
263
|
-
throw new Error("
|
|
285
|
+
throw new Error("The result is already recorded; evaluate the current task.");
|
|
264
286
|
if (task.criteria) {
|
|
265
287
|
db.prepare("UPDATE workflow_tasks SET result = ?, status = 'evaluating', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(params.result, task.id);
|
|
266
288
|
db.prepare("UPDATE workflows SET status = 'evaluating', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(item.id);
|
|
267
289
|
event(item.id, task.id, "result_recorded", { phase: params.phase, result: params.result });
|
|
268
|
-
return { content: [{ type: "text", text: `
|
|
290
|
+
return { content: [{ type: "text", text: `Result recorded. Now evaluate task #${task.position} against its criteria.` }], details: { taskId: task.id, needsEvaluation: true } };
|
|
269
291
|
}
|
|
270
292
|
db.prepare("UPDATE workflow_tasks SET result = ?, status = 'accepted', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(params.result, task.id);
|
|
271
293
|
event(item.id, task.id, "accepted_without_criteria", { result: params.result });
|
|
272
294
|
const next = activateNext(item);
|
|
273
|
-
return { content: [{ type: "text", text: next.completed ? "Task
|
|
295
|
+
return { content: [{ type: "text", text: next.completed ? "Task accepted; workflow completed." : `Task accepted. Execute task #${next.task!.position}: ${next.task!.instruction}` }], details: { next } };
|
|
274
296
|
} });
|
|
275
|
-
pi.registerTool({ name: "sequential_workflow_evaluate", label: "Evaluate Workflow Task", description: "
|
|
297
|
+
pi.registerTool({ name: "sequential_workflow_evaluate", label: "Evaluate Workflow Task", description: "Records acceptance, retry, or terminal failure of the current task in an identified workflow.", promptSnippet: "Evaluate the active workflow task", promptGuidelines: ["Use sequential_workflow_evaluate after every task with criteria and for every Evaluate task.", "Use outcome 'retry' only when repeating the task could produce a different result. Use outcome 'fail' after recording evidence when the task cannot safely progress; it ends the workflow without advancing or retrying.", "The legacy accepted boolean remains supported: true means accept and false means retry."], parameters: Type.Object({ workflowId: Type.Integer({ minimum: 1 }), taskId: Type.Integer({ minimum: 1 }), outcome: Type.Optional(evaluationOutcome), accepted: Type.Optional(Type.Boolean()), reasoning: Type.String({ minLength: 1 }), decision: Type.Optional(Type.Object({ evaluator: Type.Optional(Type.String()), model: Type.Optional(Type.String()), confidence: Type.Optional(Type.Number()), probabilities: Type.Optional(Type.Record(Type.String(), Type.Number())) })) }), async execute(_id, params): Promise<{
|
|
276
298
|
content: {
|
|
277
299
|
type: "text";
|
|
278
300
|
text: string;
|
|
@@ -282,14 +304,34 @@ CREATE TABLE IF NOT EXISTS workflow_events (id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
|
282
304
|
const item = requireActiveWorkflow(params.workflowId);
|
|
283
305
|
const task = currentTask(item.id);
|
|
284
306
|
if (!task)
|
|
285
|
-
throw new Error("
|
|
307
|
+
throw new Error("There is no pending task.");
|
|
286
308
|
if (task.type !== "evaluate" && task.status !== "evaluating")
|
|
287
|
-
throw new Error("
|
|
288
|
-
if (params.accepted)
|
|
289
|
-
|
|
309
|
+
throw new Error("Record the task result before evaluating it.");
|
|
310
|
+
if (!params.outcome && typeof params.accepted !== "boolean")
|
|
311
|
+
throw new Error("Provide an evaluation outcome.");
|
|
312
|
+
if (params.outcome && typeof params.accepted === "boolean")
|
|
313
|
+
throw new Error("Provide outcome or accepted, not both.");
|
|
314
|
+
const outcome = params.outcome ?? (params.accepted ? "accept" : "retry");
|
|
315
|
+
const evaluation = evaluationRecord(task, outcome, params.reasoning, params.decision);
|
|
316
|
+
if (outcome === "fail") {
|
|
317
|
+
db.prepare("UPDATE workflow_tasks SET status = 'failed', evaluation = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(evaluation, task.id);
|
|
318
|
+
db.prepare("UPDATE workflows SET status = 'failed', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(item.id);
|
|
319
|
+
event(item.id, task.id, "failed_evaluation", { reasoning: params.reasoning, attempts: task.attempts });
|
|
320
|
+
resolveParentAfterChild(workflow(item.id)!);
|
|
321
|
+
return { content: [{ type: "text", text: `Task #${task.position} failed by terminal evaluation; workflow failed.` }], details: { accepted: false, failed: true, terminal: true, task: taskSummary(currentTask(item.id) ?? task) } };
|
|
322
|
+
}
|
|
323
|
+
if (outcome === "accept") {
|
|
324
|
+
db.prepare("UPDATE workflow_tasks SET status = 'accepted', evaluation = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(evaluation, task.id);
|
|
290
325
|
event(item.id, task.id, "accepted", { reasoning: params.reasoning });
|
|
291
326
|
const next = activateNext(item);
|
|
292
|
-
return { content: [{ type: "text", text: next.completed ? "
|
|
327
|
+
return { content: [{ type: "text", text: next.completed ? "Evaluation accepted; workflow completed." : `Evaluation accepted. Execute only task #${next.task!.position}: ${next.task!.instruction}` }], details: { accepted: true, next } };
|
|
328
|
+
}
|
|
329
|
+
if (task.type === "action" && task.attempts + 1 >= maxActionAttempts) {
|
|
330
|
+
db.prepare("UPDATE workflow_tasks SET status = 'failed', attempts = attempts + 1, evaluation = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(evaluation, task.id);
|
|
331
|
+
db.prepare("UPDATE workflows SET status = 'failed', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(item.id);
|
|
332
|
+
event(item.id, task.id, "failed_attempt_limit", { reasoning: params.reasoning, attempts: task.attempts + 1, maxAttempts: maxActionAttempts });
|
|
333
|
+
resolveParentAfterChild(workflow(item.id)!);
|
|
334
|
+
return { content: [{ type: "text", text: `Task #${task.position} failed after ${maxActionAttempts} rejected attempts; workflow failed.` }], details: { accepted: false, failed: true, attempts: task.attempts + 1, maxAttempts: maxActionAttempts } };
|
|
293
335
|
}
|
|
294
336
|
if (task.type === "workflow") {
|
|
295
337
|
event(item.id, task.id, "child_detached", { childWorkflowId: task.child_workflow_id });
|
|
@@ -298,10 +340,10 @@ CREATE TABLE IF NOT EXISTS workflow_events (id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
|
298
340
|
db.prepare("DELETE FROM workflow_collect_checkpoint WHERE task_id=?").run(task.id);
|
|
299
341
|
const retryStatus: TaskStatus = task.type === "collect" ? "awaiting_user" : "running";
|
|
300
342
|
const workflowStatus: WorkflowStatus = task.type === "collect" ? "awaiting_user" : "running";
|
|
301
|
-
db.prepare("UPDATE workflow_tasks SET status = ?, attempts = attempts + 1, evaluation = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(retryStatus,
|
|
343
|
+
db.prepare("UPDATE workflow_tasks SET status = ?, attempts = attempts + 1, evaluation = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(retryStatus, evaluation, task.id);
|
|
302
344
|
db.prepare("UPDATE workflows SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(workflowStatus, item.id);
|
|
303
345
|
event(item.id, task.id, "rejected", { reasoning: params.reasoning });
|
|
304
|
-
return { content: [{ type: "text", text: task.type === "collect" ? `
|
|
346
|
+
return { content: [{ type: "text", text: task.type === "collect" ? `Criteria rejected. Remain on task #${task.position}.` : `Criteria rejected. Remain on task #${task.position} and repeat the action.` }], details: { accepted: false, task: taskSummary(currentTask(item.id)!) } };
|
|
305
347
|
} });
|
|
306
348
|
pi.registerTool({ name: "sequential_workflow_cancel", label: "Cancel Sequential Workflow", description: "Cancela um workflow ativo deste profile, sem cancelar seus descendentes.", promptSnippet: "Cancel a Sequential Workflow", promptGuidelines: ["Use sequential_workflow_cancel only after the user explicitly asks to cancel a named Sequential Workflow.", "sequential_workflow_cancel can cancel a workflow created in another session of the same profile; do not require adoption first.", "Cancelling a child workflow lets its parent handle the terminal child state; do not cancel descendants automatically."], parameters: Type.Object({ workflowId: Type.Integer({ minimum: 1 }) }), async execute(_id, params): Promise<{
|
|
307
349
|
content: {
|
|
@@ -310,7 +352,7 @@ CREATE TABLE IF NOT EXISTS workflow_events (id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
|
310
352
|
}[];
|
|
311
353
|
details: Record<string, unknown>;
|
|
312
354
|
}> { return cancelWorkflow(params.workflowId); } });
|
|
313
|
-
pi.registerTool({ name: "sequential_workflow_status", label: "Sequential Workflow Status", description: "
|
|
355
|
+
pi.registerTool({ name: "sequential_workflow_status", label: "Sequential Workflow Status", description: "Lists active workflows or inspects the persisted state of an identified workflow.", promptGuidelines: ["Use sequential_workflow_status only when the user explicitly asks to inspect, list, or continue a persisted Sequential Workflow. Never select or resume an existing workflow merely because it is active."], parameters: Type.Object({ workflowId: Type.Optional(Type.Integer({ minimum: 1 })), includeChildren: Type.Optional(Type.Boolean()) }), async execute(_id, params): Promise<{
|
|
314
356
|
content: {
|
|
315
357
|
type: "text";
|
|
316
358
|
text: string;
|
|
@@ -318,15 +360,15 @@ CREATE TABLE IF NOT EXISTS workflow_events (id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
|
318
360
|
details: Record<string, unknown>;
|
|
319
361
|
}> {
|
|
320
362
|
if (params.workflowId === undefined) {
|
|
321
|
-
const items = many<Workflow>("SELECT id, title, source, status, parent_workflow_id FROM workflows WHERE session_id = ? AND status IN ('running', 'awaiting_user', 'evaluating') ORDER BY id DESC", harness.session());
|
|
322
|
-
return { content: [{ type: "text", text: items.length ? items.map((item) => `#${item.id} ${item.title} (${item.status})${item.parent_workflow_id ? `,
|
|
363
|
+
const items = many<Workflow>("SELECT id, title, source, status, parent_workflow_id FROM workflows WHERE session_id = ? AND status IN ('pending_definition', 'running', 'awaiting_user', 'evaluating') ORDER BY id DESC", harness.session());
|
|
364
|
+
return { content: [{ type: "text", text: items.length ? items.map((item) => `#${item.id} ${item.title} (${item.status})${item.parent_workflow_id ? `, parent #${item.parent_workflow_id}` : ""}; current task #${currentTask(item.id)?.position ?? "none"}.`).join("\n") : "There are no active workflows." }], details: { workflows: items.map((item) => ({ ...item, currentTask: currentTask(item.id) })) } };
|
|
323
365
|
}
|
|
324
366
|
const item = workflow(params.workflowId);
|
|
325
367
|
if (!item)
|
|
326
|
-
throw new Error(`Workflow #${params.workflowId}
|
|
368
|
+
throw new Error(`Workflow #${params.workflowId} does not exist.`);
|
|
327
369
|
const tasks = many<Task>("SELECT id, workflow_id, position, type, instruction, criteria, status, attempts, result, evaluation, child_workflow_id FROM workflow_tasks WHERE workflow_id = ? ORDER BY position", item.id);
|
|
328
370
|
const children = params.includeChildren ? many<Workflow>("SELECT id, title, source, status, parent_workflow_id FROM workflows WHERE parent_workflow_id = ? ORDER BY id", item.id) : [];
|
|
329
|
-
return { content: [{ type: "text", text: `Workflow #${item.id} (${item.status}).
|
|
371
|
+
return { content: [{ type: "text", text: `Workflow #${item.id} (${item.status}). Current task: ${currentTask(item.id)?.position ?? "none"}.` }], details: { workflow: item, tasks, children } };
|
|
330
372
|
} });
|
|
331
373
|
pi.registerCommand("workflow-status", { description: "Show this session's workflows", handler: async (args, ctx) => {
|
|
332
374
|
try {
|
|
@@ -50,7 +50,22 @@ export class SkillSourceStore {
|
|
|
50
50
|
return this.db.prepare("SELECT source_identifier,source_relative_path,target_path FROM skill_source_installations WHERE profile=? AND skill_name=?").get(profile, skillName) as Row | undefined;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
async
|
|
53
|
+
private async replacePublishedSkill(source: string, target: string): Promise<void> {
|
|
54
|
+
const temporary = `${target}.publish-${randomUUID()}`, backup = `${target}.backup-${randomUUID()}`;
|
|
55
|
+
try {
|
|
56
|
+
await rm(temporary, { recursive: true, force: true });
|
|
57
|
+
await cp(source, temporary, { recursive: true, filter: (path) => !path.split("/").includes("node_modules") && !path.split("/").includes(".git") });
|
|
58
|
+
if (existsSync(target)) await rename(target, backup);
|
|
59
|
+
await rename(temporary, target);
|
|
60
|
+
await rm(backup, { recursive: true, force: true });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
await rm(temporary, { recursive: true, force: true });
|
|
63
|
+
if (!existsSync(target) && existsSync(backup)) await rename(backup, target);
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async publish(identifier: string, profile: string, skillName: string, skillPath: string, confirmOverwrite = false) {
|
|
54
69
|
const previous = this.publishing.get(identifier) ?? Promise.resolve();
|
|
55
70
|
let release!: () => void;
|
|
56
71
|
const queued = new Promise<void>((resolveRun) => { release = resolveRun; });
|
|
@@ -66,17 +81,21 @@ export class SkillSourceStore {
|
|
|
66
81
|
await this.validateSkillDirectory(local);
|
|
67
82
|
const installation = this.installation(profile, skillName);
|
|
68
83
|
if (installation && String(installation.source_identifier) !== identifier) throw Object.assign(new Error("This skill was imported from a different Skill Source. Publish it there or create a new local skill."), { status: 409 });
|
|
69
|
-
const targetRelative = installation ? String(installation.source_relative_path) : join(source.basePath, skillName).replaceAll("\\", "/");
|
|
70
|
-
if (targetRelative.split("/").includes("..") || targetRelative.startsWith("/")) throw Object.assign(new Error("Invalid skill destination."), { status: 400 });
|
|
71
|
-
const sameName = this.skills(identifier).filter((item) => item.name === skillName && item.relativePath !== targetRelative && item.status === "synced");
|
|
72
|
-
if (sameName.length) throw Object.assign(new Error(`A different skill named '${skillName}' already exists in this Skill Source. Publish by its original path or rename the local skill.`), { status: 409 });
|
|
73
84
|
const refreshed = await this.sync(identifier);
|
|
74
85
|
if (refreshed.status !== "succeeded") throw Object.assign(new Error(refreshed.error ?? "Unable to synchronize the Skill Source before publishing."), { status: 502 });
|
|
86
|
+
const available = this.skills(identifier).filter((item) => item.status === "synced");
|
|
87
|
+
const matching = available.filter((item) => item.name === skillName);
|
|
88
|
+
if (!installation && matching.length > 1) throw Object.assign(new Error(`Multiple Skills named '${skillName}' exist in this Skill Source. Rename the local Skill before publishing.`), { status: 409 });
|
|
89
|
+
const sourceRelative = installation ? String(installation.source_relative_path) : matching[0]?.relativePath ?? skillName;
|
|
90
|
+
if (sourceRelative.split("/").includes("..") || sourceRelative.startsWith("/")) throw Object.assign(new Error("Invalid skill destination."), { status: 400 });
|
|
91
|
+
const existing = available.find((item) => item.relativePath === sourceRelative);
|
|
92
|
+
if (installation && !existing) throw Object.assign(new Error("The Skill no longer exists in its Skill Source. Import it again or publish it as a new local Skill."), { status: 409 });
|
|
93
|
+
const targetRelative = join(source.basePath, sourceRelative).replaceAll("\\", "/");
|
|
94
|
+
if (existing && !confirmOverwrite) return { action: "confirmation_required" as const, source: identifier, relativePath: targetRelative, description: existing.description, commitHash: existing.commitHash };
|
|
75
95
|
const repository = resolve(this.root, ".git-cache", identifier), target = resolve(repository, targetRelative);
|
|
76
96
|
if (!target.startsWith(`${repository}/`)) throw Object.assign(new Error("Invalid skill destination."), { status: 400 });
|
|
77
|
-
await rm(target, { recursive: true, force: true });
|
|
78
97
|
await mkdir(dirname(target), { recursive: true });
|
|
79
|
-
await
|
|
98
|
+
await this.replacePublishedSkill(local, target);
|
|
80
99
|
const token = String((this.db.prepare("SELECT credential_token FROM skill_sources WHERE identifier=?").get(identifier) as Row).credential_token);
|
|
81
100
|
const askpass = join(this.root, `.git-askpass-publish-${randomUUID()}`), env: NodeJS.ProcessEnv = {
|
|
82
101
|
...process.env,
|
|
@@ -101,9 +120,9 @@ export class SkillSourceStore {
|
|
|
101
120
|
} finally { await rm(askpass, { force: true }); }
|
|
102
121
|
const synced = await this.sync(identifier);
|
|
103
122
|
if (synced.status !== "succeeded") throw Object.assign(new Error(synced.error ?? "The Skill Source was pushed but could not be synchronized."), { status: 502 });
|
|
104
|
-
const published = this.skills(identifier).find((item) => item.relativePath ===
|
|
105
|
-
if (published) this.db.prepare("INSERT INTO skill_source_installations VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(profile,skill_name) DO UPDATE SET source_identifier=excluded.source_identifier,source_relative_path=excluded.source_relative_path,source_commit_hash=excluded.source_commit_hash,source_content_hash=excluded.source_content_hash,target_path=excluded.target_path,installed_at=excluded.installed_at").run(profile,skillName,identifier,
|
|
106
|
-
return { action:
|
|
123
|
+
const published = this.skills(identifier).find((item) => item.relativePath === sourceRelative);
|
|
124
|
+
if (published) this.db.prepare("INSERT INTO skill_source_installations VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(profile,skill_name) DO UPDATE SET source_identifier=excluded.source_identifier,source_relative_path=excluded.source_relative_path,source_commit_hash=excluded.source_commit_hash,source_content_hash=excluded.source_content_hash,target_path=excluded.target_path,installed_at=excluded.installed_at").run(profile,skillName,identifier,sourceRelative,published.commitHash,published.contentHash,local,now());
|
|
125
|
+
return { action: existing ? "updated" as const : "created" as const, source: identifier, relativePath: targetRelative, commitHash: synced.commitHash };
|
|
107
126
|
} finally { release(); if (this.publishing.get(identifier) === chain) this.publishing.delete(identifier); }
|
|
108
127
|
}
|
|
109
128
|
|