@rosetears/aili-pi 0.1.8 → 0.1.10
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 +7 -7
- package/THIRD_PARTY_NOTICES.md +12 -12
- package/docs/persistent-agents.md +114 -0
- package/extensions/matrix/index.ts +24 -8
- package/extensions/zentui/config.ts +13 -13
- package/extensions/zentui/format.ts +15 -1
- package/extensions/zentui/gradient.ts +22 -13
- package/extensions/zentui/tool-execution.ts +7 -4
- package/manifests/adapter-evidence.json +53 -20
- package/manifests/capabilities.json +3 -3
- package/manifests/live-verification.json +18 -19
- package/manifests/provenance.json +12 -12
- package/manifests/roles.json +270 -57
- package/manifests/sbom.json +1 -128
- package/manifests/skill-compatibility.json +57 -61
- package/manifests/subagent-provenance.json +8 -15
- package/package.json +3 -3
- package/roles/agent-evaluator.md +8 -3
- package/roles/ai-regression-scout.md +8 -3
- package/roles/browser-qa-runner.md +8 -3
- package/roles/code-reviewer.md +8 -3
- package/roles/code-scout.md +8 -3
- package/roles/convergence-reviewer.md +8 -3
- package/roles/doc-researcher.md +8 -3
- package/roles/e2e-artifact-runner.md +8 -3
- package/roles/general.md +49 -0
- package/roles/implementer.md +8 -3
- package/roles/opensource-sanitizer.md +8 -3
- package/roles/plan-auditor.md +8 -3
- package/roles/pr-test-analyzer.md +8 -3
- package/roles/security-auditor.md +8 -3
- package/roles/silent-failure-reviewer.md +8 -3
- package/roles/spec-miner.md +8 -3
- package/roles/test-coverage-reviewer.md +8 -3
- package/roles/test-engineer.md +8 -3
- package/roles/web-performance-auditor.md +8 -3
- package/roles/web-researcher.md +8 -3
- package/scripts/sync-roles.ts +186 -24
- package/src/runtime/doctor.ts +38 -10
- package/src/runtime/global-resources.ts +5 -1
- package/src/runtime/index.ts +2 -2
- package/src/runtime/persistent-agents/hub.ts +429 -0
- package/src/runtime/persistent-agents/model-selection.ts +363 -0
- package/src/runtime/persistent-agents/output-delivery.ts +356 -0
- package/src/runtime/persistent-agents/permission.ts +223 -0
- package/src/runtime/persistent-agents/policy.ts +311 -0
- package/src/runtime/persistent-agents/production.ts +569 -0
- package/src/runtime/persistent-agents/runtime.ts +164 -0
- package/src/runtime/persistent-agents/sandbox.ts +68 -0
- package/src/runtime/persistent-agents/scheduler.ts +190 -0
- package/src/runtime/persistent-agents/session-factory.ts +184 -0
- package/src/runtime/persistent-agents/storage.ts +775 -0
- package/src/runtime/persistent-agents/task-coordinator.ts +460 -0
- package/src/runtime/persistent-agents/task-schema.ts +141 -0
- package/src/runtime/persistent-agents/types.ts +134 -0
- package/src/runtime/persistent-agents/workspace.ts +335 -0
- package/src/runtime/registry.ts +28 -32
- package/src/runtime/roles.ts +211 -18
- package/src/runtime/rose-context.ts +1 -1
- package/templates/APPEND_SYSTEM.md +1 -1
- package/themes/rose-cyberdeck.json +5 -9
- package/upstream/opencode-global-agents.lock.json +1 -1
- package/src/runtime/subagents.ts +0 -249
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
export const COORDINATOR_SCHEMA_VERSION = 1 as const;
|
|
2
|
+
export const DEFAULT_IDLE_TTL_MS = 420_000;
|
|
3
|
+
|
|
4
|
+
export type AgentState = "queued" | "running" | "idle" | "parked" | "aborted";
|
|
5
|
+
export type JobState = "queued" | "running" | "completed" | "failed" | "aborted" | "unexecuted";
|
|
6
|
+
export type TurnState = "queued" | "running" | "completed" | "failed" | "aborted" | "interrupted";
|
|
7
|
+
|
|
8
|
+
export interface AgentRecord {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
selector: string;
|
|
12
|
+
state: AgentState;
|
|
13
|
+
parentAgentId?: string;
|
|
14
|
+
sessionPath?: string;
|
|
15
|
+
currentTurnId?: string;
|
|
16
|
+
currentJobId?: string;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
updatedAt: string;
|
|
19
|
+
metadata?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface JobRecord {
|
|
23
|
+
id: string;
|
|
24
|
+
agentId: string;
|
|
25
|
+
state: JobState;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
updatedAt: string;
|
|
28
|
+
error?: string;
|
|
29
|
+
metadata?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TurnRecord {
|
|
33
|
+
id: string;
|
|
34
|
+
agentId: string;
|
|
35
|
+
jobId?: string;
|
|
36
|
+
state: TurnState;
|
|
37
|
+
createdAt: string;
|
|
38
|
+
updatedAt: string;
|
|
39
|
+
outcome?: string;
|
|
40
|
+
metadata?: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MailboxRecord {
|
|
44
|
+
agentId: string;
|
|
45
|
+
messages: Array<Record<string, unknown>>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CoordinatorState {
|
|
49
|
+
schemaVersion: typeof COORDINATOR_SCHEMA_VERSION;
|
|
50
|
+
parentId: string;
|
|
51
|
+
lastSequence: number;
|
|
52
|
+
appliedEventIds: string[];
|
|
53
|
+
agents: Record<string, AgentRecord>;
|
|
54
|
+
releasedAgents: Record<string, AgentRecord>;
|
|
55
|
+
jobs: Record<string, JobRecord>;
|
|
56
|
+
turns: Record<string, TurnRecord>;
|
|
57
|
+
mailboxes: Record<string, MailboxRecord>;
|
|
58
|
+
deliveries: Record<string, Record<string, unknown>>;
|
|
59
|
+
models: Record<string, Record<string, unknown>>;
|
|
60
|
+
workspaces: Record<string, Record<string, unknown>>;
|
|
61
|
+
messages: Record<string, Record<string, unknown>>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type CoordinatorEventKind =
|
|
65
|
+
| "agent.created"
|
|
66
|
+
| "agent.state"
|
|
67
|
+
| "agent.session"
|
|
68
|
+
| "agent.released"
|
|
69
|
+
| "job.created"
|
|
70
|
+
| "job.state"
|
|
71
|
+
| "turn.created"
|
|
72
|
+
| "turn.state"
|
|
73
|
+
| "turn.audit"
|
|
74
|
+
| "mailbox.put"
|
|
75
|
+
| "message.put"
|
|
76
|
+
| "delivery.put"
|
|
77
|
+
| "model.put"
|
|
78
|
+
| "model.clear"
|
|
79
|
+
| "workspace.put";
|
|
80
|
+
|
|
81
|
+
export interface CoordinatorEvent {
|
|
82
|
+
schemaVersion: typeof COORDINATOR_SCHEMA_VERSION;
|
|
83
|
+
eventId: string;
|
|
84
|
+
sequence: number;
|
|
85
|
+
timestamp: string;
|
|
86
|
+
parentId: string;
|
|
87
|
+
kind: CoordinatorEventKind;
|
|
88
|
+
agentId?: string;
|
|
89
|
+
jobId?: string;
|
|
90
|
+
turnId?: string;
|
|
91
|
+
deliveryId?: string;
|
|
92
|
+
messageId?: string;
|
|
93
|
+
payload: Record<string, unknown>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface CoordinatorEventInput {
|
|
97
|
+
kind: CoordinatorEventKind;
|
|
98
|
+
agentId?: string;
|
|
99
|
+
jobId?: string;
|
|
100
|
+
turnId?: string;
|
|
101
|
+
deliveryId?: string;
|
|
102
|
+
messageId?: string;
|
|
103
|
+
payload: Record<string, unknown>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface CoordinatorSnapshot {
|
|
107
|
+
schemaVersion: typeof COORDINATOR_SCHEMA_VERSION;
|
|
108
|
+
parentId: string;
|
|
109
|
+
checkpointSequence: number;
|
|
110
|
+
createdAt: string;
|
|
111
|
+
state: CoordinatorState;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ReplayDiagnostics {
|
|
115
|
+
toleratedFinalPartialLine: boolean;
|
|
116
|
+
ignoredBytes: number;
|
|
117
|
+
snapshotLoaded: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface ReplayResult {
|
|
121
|
+
state: CoordinatorState;
|
|
122
|
+
events: CoordinatorEvent[];
|
|
123
|
+
diagnostics: ReplayDiagnostics;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface SidecarLayout {
|
|
127
|
+
parentSessionPath: string;
|
|
128
|
+
root: string;
|
|
129
|
+
coordinatorPath: string;
|
|
130
|
+
snapshotPath: string;
|
|
131
|
+
agentsDir: string;
|
|
132
|
+
patchesDir: string;
|
|
133
|
+
workspacesPath: string;
|
|
134
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import { copyFile, lstat, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import type { CoordinatorJournal } from "./storage.js";
|
|
6
|
+
import { assertSafeAgentId } from "./storage.js";
|
|
7
|
+
import type { SidecarLayout } from "./types.js";
|
|
8
|
+
import type { TaskWorkspaceMode, TaskWriteScope } from "./task-schema.js";
|
|
9
|
+
import { assertNoCredentialMaterial } from "./permission.js";
|
|
10
|
+
import type { ExtensionAPI, ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
const execFile = promisify(execFileCallback);
|
|
13
|
+
|
|
14
|
+
export interface ValidatedWriteScope {
|
|
15
|
+
paths: string[];
|
|
16
|
+
resources: string[];
|
|
17
|
+
declared: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WorkspaceDecision {
|
|
21
|
+
mode: "shared" | "isolated";
|
|
22
|
+
reason: "explicit-shared" | "explicit-isolated" | "disjoint-known-scope" | "overlapping-path-scope" | "undeclared-best-effort";
|
|
23
|
+
diagnostics: string[];
|
|
24
|
+
conflicts: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface WorkspaceLease {
|
|
28
|
+
agentId: string;
|
|
29
|
+
mode: "shared" | "isolated";
|
|
30
|
+
projectRoot: string;
|
|
31
|
+
root: string;
|
|
32
|
+
scope: ValidatedWriteScope;
|
|
33
|
+
acquiredAt: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isInside(root: string, candidate: string): boolean {
|
|
37
|
+
const rel = relative(root, candidate);
|
|
38
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeResource(resource: string): string {
|
|
42
|
+
const normalized = resource.trim();
|
|
43
|
+
if (!normalized || normalized.length > 300 || /[\r\n\0]/.test(normalized)) throw new Error(`invalid writeScope resource: ${resource}`);
|
|
44
|
+
return normalized;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function validateWriteScope(projectRoot: string, scope: TaskWriteScope): Promise<ValidatedWriteScope> {
|
|
48
|
+
const canonicalRoot = await realpath(projectRoot);
|
|
49
|
+
const paths: string[] = [];
|
|
50
|
+
for (const path of scope.paths) {
|
|
51
|
+
await assertNoCredentialMaterial(path, "writeScope path", canonicalRoot);
|
|
52
|
+
if (path.includes("*") || path.includes("?")) throw new Error(`writeScope path globs are unsupported: ${path}`);
|
|
53
|
+
const candidate = resolve(canonicalRoot, path);
|
|
54
|
+
if (!isInside(canonicalRoot, candidate)) throw new Error(`writeScope path escapes project root: ${path}`);
|
|
55
|
+
const normalized = relative(canonicalRoot, candidate).replaceAll(sep, "/") || ".";
|
|
56
|
+
paths.push(normalized);
|
|
57
|
+
}
|
|
58
|
+
const resources = scope.resources.map(normalizeResource);
|
|
59
|
+
return {
|
|
60
|
+
paths: [...new Set(paths)].sort(),
|
|
61
|
+
resources: [...new Set(resources)].sort(),
|
|
62
|
+
declared: paths.length > 0 || resources.length > 0,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function validateWorkspaceCwd(workspaceRoot: string, requestedCwd?: string): Promise<string> {
|
|
67
|
+
const canonicalRoot = await realpath(workspaceRoot);
|
|
68
|
+
const candidate = resolve(canonicalRoot, requestedCwd ?? ".");
|
|
69
|
+
if (!isInside(canonicalRoot, candidate)) throw new Error(`cwd escapes workspace root: ${requestedCwd}`);
|
|
70
|
+
try {
|
|
71
|
+
const canonical = await realpath(candidate);
|
|
72
|
+
if (!isInside(canonicalRoot, canonical)) throw new Error(`cwd symlink escapes workspace root: ${requestedCwd}`);
|
|
73
|
+
const stat = await lstat(canonical);
|
|
74
|
+
if (!stat.isDirectory()) throw new Error(`cwd is not a directory: ${requestedCwd}`);
|
|
75
|
+
return canonical;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error(`cwd does not exist: ${requestedCwd}`);
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function pathsOverlap(left: string, right: string): boolean {
|
|
83
|
+
if (left === "." || right === ".") return true;
|
|
84
|
+
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function scopeConflicts(left: ValidatedWriteScope, right: ValidatedWriteScope): string[] {
|
|
88
|
+
const conflicts: string[] = [];
|
|
89
|
+
for (const leftPath of left.paths) {
|
|
90
|
+
for (const rightPath of right.paths) if (pathsOverlap(leftPath, rightPath)) conflicts.push(`path:${leftPath}<->${rightPath}`);
|
|
91
|
+
}
|
|
92
|
+
for (const resource of left.resources) if (right.resources.includes(resource)) conflicts.push(`resource:${resource}`);
|
|
93
|
+
return [...new Set(conflicts)];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class WorkspaceLeaseManager {
|
|
97
|
+
private readonly leases = new Map<string, WorkspaceLease>();
|
|
98
|
+
private readonly observedMutations = new Map<string, string>();
|
|
99
|
+
|
|
100
|
+
decide(agentId: string, requested: TaskWorkspaceMode, projectRoot: string, scope: ValidatedWriteScope): WorkspaceDecision {
|
|
101
|
+
assertSafeAgentId(agentId);
|
|
102
|
+
const conflicts = [...this.leases.values()].flatMap((lease) => scopeConflicts(scope, lease.scope));
|
|
103
|
+
const pathConflicts = conflicts.filter((conflict) => conflict.startsWith("path:"));
|
|
104
|
+
const resourceConflicts = conflicts.filter((conflict) => conflict.startsWith("resource:"));
|
|
105
|
+
if (requested === "isolated") {
|
|
106
|
+
if (resourceConflicts.length > 0) throw new Error(`isolated workspace cannot isolate shared resources: ${resourceConflicts.join(", ")}`);
|
|
107
|
+
return { mode: "isolated", reason: "explicit-isolated", diagnostics: [], conflicts };
|
|
108
|
+
}
|
|
109
|
+
if (requested === "shared") {
|
|
110
|
+
if (conflicts.length > 0) throw new Error(`shared workspace scope conflict; retry isolated: ${conflicts.join(", ")}`);
|
|
111
|
+
return { mode: "shared", reason: "explicit-shared", diagnostics: [], conflicts: [] };
|
|
112
|
+
}
|
|
113
|
+
if (!scope.declared) {
|
|
114
|
+
return {
|
|
115
|
+
mode: "shared",
|
|
116
|
+
reason: "undeclared-best-effort",
|
|
117
|
+
diagnostics: ["writeScope is undeclared; shared conflict detection is observable best-effort, not complete containment"],
|
|
118
|
+
conflicts: [],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (resourceConflicts.length > 0) throw new Error(`auto workspace cannot isolate shared resources: ${resourceConflicts.join(", ")}`);
|
|
122
|
+
if (pathConflicts.length > 0) return { mode: "isolated", reason: "overlapping-path-scope", diagnostics: ["known path overlap selected isolated workspace"], conflicts };
|
|
123
|
+
return { mode: "shared", reason: "disjoint-known-scope", diagnostics: [], conflicts: [] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
acquire(lease: WorkspaceLease): void {
|
|
127
|
+
if (this.leases.has(lease.agentId)) throw new Error(`${lease.agentId}: workspace lease already active`);
|
|
128
|
+
const decision = this.decide(lease.agentId, lease.mode, lease.projectRoot, lease.scope);
|
|
129
|
+
if (decision.mode !== lease.mode) throw new Error(`${lease.agentId}: lease mode does not match conflict decision`);
|
|
130
|
+
this.leases.set(lease.agentId, structuredClone(lease));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
release(agentId: string): void {
|
|
134
|
+
this.leases.delete(agentId);
|
|
135
|
+
for (const [key, owner] of this.observedMutations) if (owner === agentId) this.observedMutations.delete(key);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
active(): WorkspaceLease[] {
|
|
139
|
+
return [...this.leases.values()].map((lease) => structuredClone(lease));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async assertFileMutation(agentId: string, path: string): Promise<{ allowed: true; diagnostic?: string }> {
|
|
143
|
+
const lease = this.leases.get(agentId);
|
|
144
|
+
if (!lease) throw new Error(`${agentId}: no active workspace lease`);
|
|
145
|
+
await assertNoCredentialMaterial(path, "workspace mutation", lease.root);
|
|
146
|
+
const absolute = resolve(lease.root, path);
|
|
147
|
+
if (!isInside(lease.root, absolute)) throw new Error(`${agentId}: mutation escapes workspace root`);
|
|
148
|
+
const relativePath = relative(lease.root, absolute).replaceAll(sep, "/") || ".";
|
|
149
|
+
if (lease.scope.declared && lease.scope.paths.length > 0 && !lease.scope.paths.some((declared) => pathsOverlap(declared, relativePath))) {
|
|
150
|
+
throw new Error(`${agentId}: mutation is outside declared writeScope; retry with corrected scope`);
|
|
151
|
+
}
|
|
152
|
+
if (lease.mode === "isolated") return { allowed: true };
|
|
153
|
+
const key = `path:${relative(projectRootOf(lease), absolute).replaceAll(sep, "/")}`;
|
|
154
|
+
const owner = this.observedMutations.get(key);
|
|
155
|
+
if (owner && owner !== agentId) throw new Error(`${agentId}: second conflicting mutation blocked; retry in isolated workspace (owner ${owner})`);
|
|
156
|
+
for (const other of this.leases.values()) {
|
|
157
|
+
if (other.agentId === agentId || other.mode === "isolated") continue;
|
|
158
|
+
if (scopeConflicts({ paths: [relativePath], resources: [], declared: true }, other.scope).length > 0) {
|
|
159
|
+
throw new Error(`${agentId}: mutation conflicts with ${other.agentId}; retry in isolated workspace`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
this.observedMutations.set(key, agentId);
|
|
163
|
+
return { allowed: true, diagnostic: lease.scope.declared ? undefined : "undeclared write observed and leased best-effort" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
assertResourceMutation(agentId: string, resource: string): { allowed: true } {
|
|
167
|
+
const lease = this.leases.get(agentId);
|
|
168
|
+
if (!lease) throw new Error(`${agentId}: no active workspace lease`);
|
|
169
|
+
const normalized = normalizeResource(resource);
|
|
170
|
+
if (lease.scope.declared && lease.scope.resources.length > 0 && !lease.scope.resources.includes(normalized)) {
|
|
171
|
+
throw new Error(`${agentId}: resource is outside declared writeScope`);
|
|
172
|
+
}
|
|
173
|
+
const key = `resource:${normalized}`;
|
|
174
|
+
const owner = this.observedMutations.get(key);
|
|
175
|
+
if (owner && owner !== agentId) throw new Error(`${agentId}: second conflicting resource mutation blocked; owner ${owner}`);
|
|
176
|
+
this.observedMutations.set(key, agentId);
|
|
177
|
+
return { allowed: true };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function createWorkspaceMutationGuard(manager: WorkspaceLeaseManager, agentId: string): ExtensionFactory {
|
|
182
|
+
return (pi: ExtensionAPI) => {
|
|
183
|
+
pi.on("tool_call", async (event) => {
|
|
184
|
+
if ((event.toolName !== "write" && event.toolName !== "edit") || !event.input || typeof event.input !== "object") return undefined;
|
|
185
|
+
const path = (event.input as Record<string, unknown>).path;
|
|
186
|
+
if (typeof path !== "string") return { block: true, reason: "workspace mutation path is missing" };
|
|
187
|
+
try {
|
|
188
|
+
await manager.assertFileMutation(agentId, path);
|
|
189
|
+
return undefined;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
return { block: true, reason: error instanceof Error ? error.message : String(error) };
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function projectRootOf(lease: WorkspaceLease): string {
|
|
198
|
+
return lease.mode === "isolated" ? lease.projectRoot : lease.root;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function git(cwd: string, args: string[], allowFailure = false): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
202
|
+
try {
|
|
203
|
+
const result = await execFile("git", args, { cwd, encoding: "utf8" });
|
|
204
|
+
return { stdout: result.stdout, stderr: result.stderr, code: 0 };
|
|
205
|
+
} catch (error) {
|
|
206
|
+
const failure = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: number };
|
|
207
|
+
if (allowFailure) return { stdout: failure.stdout ?? "", stderr: failure.stderr ?? "", code: typeof failure.code === "number" ? failure.code : 1 };
|
|
208
|
+
throw new Error(`git ${args.join(" ")} failed: ${(failure.stderr ?? failure.message).trim()}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface IsolatedWorkspaceRecord {
|
|
213
|
+
agentId: string;
|
|
214
|
+
mode: "isolated";
|
|
215
|
+
status: "active" | "finalized" | "cleaned" | "cleanup-failed";
|
|
216
|
+
projectRoot: string;
|
|
217
|
+
root: string;
|
|
218
|
+
branch: string;
|
|
219
|
+
baselineCommit: string;
|
|
220
|
+
resultCommit?: string;
|
|
221
|
+
patchPath?: string;
|
|
222
|
+
mainHead: string;
|
|
223
|
+
mainStatus: string;
|
|
224
|
+
diagnostics: string[];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export class GitIsolationAdapter {
|
|
228
|
+
constructor(
|
|
229
|
+
private readonly layout: SidecarLayout,
|
|
230
|
+
private readonly journal: CoordinatorJournal,
|
|
231
|
+
private readonly clock: () => Date = () => new Date(),
|
|
232
|
+
) {}
|
|
233
|
+
|
|
234
|
+
async create(agentId: string, projectRoot: string): Promise<IsolatedWorkspaceRecord> {
|
|
235
|
+
assertSafeAgentId(agentId);
|
|
236
|
+
const canonicalProject = await realpath(projectRoot);
|
|
237
|
+
const top = (await git(canonicalProject, ["rev-parse", "--show-toplevel"])).stdout.trim();
|
|
238
|
+
if (await realpath(top) !== canonicalProject) throw new Error("isolated workspace requires the declared project root to be the Git top-level");
|
|
239
|
+
const mainHead = (await git(canonicalProject, ["rev-parse", "HEAD"])).stdout.trim();
|
|
240
|
+
const mainStatus = (await git(canonicalProject, ["status", "--porcelain=v1", "-z"])).stdout;
|
|
241
|
+
const baselinePatch = (await git(canonicalProject, ["diff", "--binary", "HEAD"])).stdout;
|
|
242
|
+
const untracked = (await git(canonicalProject, ["ls-files", "--others", "--exclude-standard", "-z"])).stdout.split("\0").filter(Boolean);
|
|
243
|
+
const workspacesRoot = resolve(this.layout.root, "workspaces");
|
|
244
|
+
await mkdir(workspacesRoot, { recursive: true, mode: 0o700 });
|
|
245
|
+
const isolatedRoot = resolve(workspacesRoot, agentId);
|
|
246
|
+
if (!isInside(workspacesRoot, isolatedRoot)) throw new Error("isolated workspace path escapes sidecar");
|
|
247
|
+
const branch = `aili-agent/${agentId.toLowerCase()}-${this.clock().getTime()}`;
|
|
248
|
+
const diagnostics: string[] = [];
|
|
249
|
+
let added = false;
|
|
250
|
+
try {
|
|
251
|
+
await git(canonicalProject, ["worktree", "add", "--detach", isolatedRoot, mainHead]);
|
|
252
|
+
added = true;
|
|
253
|
+
await git(isolatedRoot, ["switch", "-c", branch]);
|
|
254
|
+
if (baselinePatch) {
|
|
255
|
+
const baselinePatchPath = resolve(this.layout.patchesDir, `${agentId}.baseline.patch`);
|
|
256
|
+
await writeFile(baselinePatchPath, baselinePatch, { encoding: "utf8", mode: 0o600 });
|
|
257
|
+
await git(isolatedRoot, ["apply", "--whitespace=nowarn", baselinePatchPath]);
|
|
258
|
+
}
|
|
259
|
+
for (const path of untracked) {
|
|
260
|
+
const source = resolve(canonicalProject, path);
|
|
261
|
+
const target = resolve(isolatedRoot, path);
|
|
262
|
+
if (!isInside(canonicalProject, source) || !isInside(isolatedRoot, target)) throw new Error(`untracked path escapes repository: ${path}`);
|
|
263
|
+
const stat = await lstat(source);
|
|
264
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`untracked isolation supports real files only: ${path}`);
|
|
265
|
+
await mkdir(resolve(target, ".."), { recursive: true });
|
|
266
|
+
await copyFile(source, target);
|
|
267
|
+
}
|
|
268
|
+
await git(isolatedRoot, ["add", "-A"]);
|
|
269
|
+
await git(isolatedRoot, ["-c", "user.name=AILI Agent", "-c", "user.email=aili-agent@example.invalid", "commit", "--allow-empty", "-m", "AILI projected dirty baseline"]);
|
|
270
|
+
const baselineCommit = (await git(isolatedRoot, ["rev-parse", "HEAD"])).stdout.trim();
|
|
271
|
+
diagnostics.push(`projected ${untracked.length} untracked files and ${baselinePatch ? "a dirty patch" : "no tracked diff"}`);
|
|
272
|
+
const record: IsolatedWorkspaceRecord = {
|
|
273
|
+
agentId,
|
|
274
|
+
mode: "isolated",
|
|
275
|
+
status: "active",
|
|
276
|
+
projectRoot: canonicalProject,
|
|
277
|
+
root: isolatedRoot,
|
|
278
|
+
branch,
|
|
279
|
+
baselineCommit,
|
|
280
|
+
mainHead,
|
|
281
|
+
mainStatus,
|
|
282
|
+
diagnostics,
|
|
283
|
+
};
|
|
284
|
+
await this.put(record);
|
|
285
|
+
return record;
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (added) await git(canonicalProject, ["worktree", "remove", "--force", isolatedRoot], true);
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async finalize(record: IsolatedWorkspaceRecord): Promise<IsolatedWorkspaceRecord> {
|
|
293
|
+
if (record.status !== "active" && record.status !== "finalized") throw new Error(`${record.agentId}: isolated workspace is not active or finalized`);
|
|
294
|
+
await git(record.root, ["add", "-A"]);
|
|
295
|
+
await git(record.root, ["-c", "user.name=AILI Agent", "-c", "user.email=aili-agent@example.invalid", "commit", "--allow-empty", "-m", `AILI Agent result ${record.agentId}`]);
|
|
296
|
+
const resultCommit = (await git(record.root, ["rev-parse", "HEAD"])).stdout.trim();
|
|
297
|
+
const patch = (await git(record.root, ["diff", "--binary", record.baselineCommit, resultCommit])).stdout;
|
|
298
|
+
const patchPath = resolve(this.layout.patchesDir, `${record.agentId}.patch`);
|
|
299
|
+
await writeFile(patchPath, patch, { encoding: "utf8", mode: 0o600 });
|
|
300
|
+
const currentHead = (await git(record.projectRoot, ["rev-parse", "HEAD"])).stdout.trim();
|
|
301
|
+
const currentStatus = (await git(record.projectRoot, ["status", "--porcelain=v1", "-z"])).stdout;
|
|
302
|
+
if (currentHead !== record.mainHead || currentStatus !== record.mainStatus) {
|
|
303
|
+
throw new Error(`${record.agentId}: main workspace changed during isolation; result preserved but automatic reconciliation is forbidden`);
|
|
304
|
+
}
|
|
305
|
+
const finalized: IsolatedWorkspaceRecord = { ...record, status: "finalized", resultCommit, patchPath };
|
|
306
|
+
await this.put(finalized);
|
|
307
|
+
return finalized;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async cleanup(record: IsolatedWorkspaceRecord): Promise<IsolatedWorkspaceRecord> {
|
|
311
|
+
if (record.status !== "active" && record.status !== "finalized") throw new Error(`${record.agentId}: isolated workspace cannot be cleaned from ${record.status}`);
|
|
312
|
+
const result = await git(record.projectRoot, ["worktree", "remove", "--force", record.root], true);
|
|
313
|
+
if (result.code !== 0) {
|
|
314
|
+
const failed: IsolatedWorkspaceRecord = { ...record, status: "cleanup-failed", diagnostics: [...record.diagnostics, result.stderr.trim() || "worktree cleanup failed"] };
|
|
315
|
+
await this.put(failed);
|
|
316
|
+
throw new Error(`${record.agentId}: isolated workspace cleanup failed; artifact retained at ${record.root}`);
|
|
317
|
+
}
|
|
318
|
+
const cleaned: IsolatedWorkspaceRecord = { ...record, status: "cleaned" };
|
|
319
|
+
await this.put(cleaned);
|
|
320
|
+
return cleaned;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
assertResumable(agentId: string): IsolatedWorkspaceRecord | undefined {
|
|
324
|
+
const raw = this.journal.getState().workspaces[agentId];
|
|
325
|
+
if (!raw || raw.mode !== "isolated") return undefined;
|
|
326
|
+
const record = raw as unknown as IsolatedWorkspaceRecord;
|
|
327
|
+
if (record.status === "cleaned") throw new Error(`${agentId}: isolated workspace was cleaned; transcript/output remain readable but Agent cannot revive`);
|
|
328
|
+
if (record.status === "cleanup-failed") throw new Error(`${agentId}: isolated workspace cleanup failed; resolve retained artifact before revive`);
|
|
329
|
+
return structuredClone(record);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private async put(record: IsolatedWorkspaceRecord): Promise<void> {
|
|
333
|
+
await this.journal.append({ kind: "workspace.put", agentId: record.agentId, payload: record as unknown as Record<string, unknown> });
|
|
334
|
+
}
|
|
335
|
+
}
|
package/src/runtime/registry.ts
CHANGED
|
@@ -128,7 +128,7 @@ export async function validateStableRelease(): Promise<string[]> {
|
|
|
128
128
|
}
|
|
129
129
|
try {
|
|
130
130
|
const roles = (await readdir(new URL("roles/", ROOT))).filter((name) => name.endsWith(".md"));
|
|
131
|
-
if (roles.length !==
|
|
131
|
+
if (roles.length !== 20) errors.push(`roles: expected 20 bundled profiles, found ${roles.length}`);
|
|
132
132
|
for (const error of await validateRoleProfiles()) errors.push(`roles: ${error}`);
|
|
133
133
|
for (const role of await loadRoleProfiles()) {
|
|
134
134
|
if (role.status === "blocked") errors.push(`role ${role.name}: blocked (${role.compatibilityReason})`);
|
|
@@ -144,46 +144,42 @@ export async function validateLiveVerification(): Promise<string[]> {
|
|
|
144
144
|
const errors: string[] = [];
|
|
145
145
|
try {
|
|
146
146
|
const evidence = await json<{
|
|
147
|
-
schemaVersion?: number;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
resolvedBackend?: string | null;
|
|
154
|
-
}>;
|
|
147
|
+
schemaVersion?: number;
|
|
148
|
+
platform?: string;
|
|
149
|
+
piVersion?: string;
|
|
150
|
+
runtime?: string;
|
|
151
|
+
status?: string;
|
|
152
|
+
probes?: Array<{ id?: string; status?: string; changedFiles?: number | null }>;
|
|
155
153
|
implementation?: Record<string, string>;
|
|
156
154
|
}>("manifests/live-verification.json");
|
|
157
155
|
if (
|
|
158
|
-
evidence.schemaVersion !==
|
|
156
|
+
evidence.schemaVersion !== 3 ||
|
|
159
157
|
evidence.platform !== "linux" ||
|
|
160
158
|
evidence.piVersion !== "0.81.1" ||
|
|
161
|
-
evidence.
|
|
162
|
-
evidence.status !== "passed"
|
|
159
|
+
evidence.runtime !== "aili-persistent-agents-v1"
|
|
163
160
|
) {
|
|
164
|
-
errors.push("live verification:
|
|
161
|
+
errors.push("live verification: persistent Agent identity is incomplete or stale");
|
|
165
162
|
}
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
credentialProbe.changedFiles !== 0
|
|
180
|
-
) {
|
|
181
|
-
errors.push("live verification: default-path/headless probes are missing, ambiguous, non-pass, or mutated files");
|
|
163
|
+
const requiredProbeIds = ["provider-turn", "child-sandbox", "external-workspace-lifecycle"] as const;
|
|
164
|
+
const probeLabels: Record<(typeof requiredProbeIds)[number], string> = {
|
|
165
|
+
"provider-turn": "provider",
|
|
166
|
+
"child-sandbox": "sandbox",
|
|
167
|
+
"external-workspace-lifecycle": "external-workspace",
|
|
168
|
+
};
|
|
169
|
+
const probes = evidence.probes ?? [];
|
|
170
|
+
const missingProbes = requiredProbeIds.filter(
|
|
171
|
+
(id) => !probes.some((probe) => probe.id === id && probe.status === "passed" && probe.changedFiles === 0),
|
|
172
|
+
);
|
|
173
|
+
if (evidence.status !== "passed" || missingProbes.length > 0) {
|
|
174
|
+
const unresolved = missingProbes.length > 0 ? missingProbes.map((id) => probeLabels[id]).join("/") : "overall-status";
|
|
175
|
+
errors.push(`live verification: persistent Agent ${unresolved} lifecycle evidence remains unverified`);
|
|
182
176
|
}
|
|
183
177
|
const requiredImplementation = [
|
|
184
|
-
"src/runtime/
|
|
185
|
-
"
|
|
186
|
-
"
|
|
178
|
+
"src/runtime/persistent-agents/production.ts",
|
|
179
|
+
"src/runtime/persistent-agents/runtime.ts",
|
|
180
|
+
"src/runtime/persistent-agents/sandbox.ts",
|
|
181
|
+
"src/runtime/persistent-agents/permission.ts",
|
|
182
|
+
"src/runtime/persistent-agents/workspace.ts",
|
|
187
183
|
];
|
|
188
184
|
for (const [filePath, expected] of Object.entries(evidence.implementation ?? {})) {
|
|
189
185
|
const content = await readFile(new URL(filePath, ROOT), "utf8");
|