@op1/threads 0.1.7 → 0.2.0
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 +88 -10
- package/docs/dynamic-workflows-plan.md +46 -0
- package/docs/workflow-capacity-findings.md +47 -0
- package/docs/workflows-verification.md +105 -0
- package/docs/workflows.md +60 -0
- package/index.ts +2 -0
- package/package.json +21 -5
- package/skills/workflow-authoring/SKILL.md +50 -0
- package/skills/workflow-authoring/references/runtime.md +87 -0
- package/src/activity-model.ts +152 -0
- package/src/activity-picker.tsx +175 -0
- package/src/activity-rail.ts +135 -0
- package/src/activity-theme.ts +49 -0
- package/src/activity.ts +781 -0
- package/src/permissions.ts +2 -2
- package/src/threads.ts +201 -32
- package/src/workflow-engine.ts +1049 -0
- package/src/workflow-rpc.ts +39 -0
- package/src/workflow-runtime-interpreter.ts +368 -0
- package/src/workflow-runtime-protocol.ts +36 -0
- package/src/workflow-runtime-worker.ts +64 -0
- package/src/workflow-runtime.ts +144 -0
- package/src/workflow-saved.ts +88 -0
- package/src/workflow-store.ts +103 -0
- package/src/workflow-types.ts +144 -0
- package/src/workflow-ui.tsx +251 -0
- package/src/workflow-worker.ts +210 -0
- package/src/workflows.ts +180 -0
- package/tui.ts +58 -8
package/src/permissions.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Permission } from "@opencode/schema/permission";
|
|
2
2
|
|
|
3
|
-
function
|
|
3
|
+
export function permissionMatches(pattern: string, value: string) {
|
|
4
4
|
const expression = pattern
|
|
5
5
|
.replaceAll("\\", "/")
|
|
6
6
|
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
@@ -19,7 +19,7 @@ export function delegationEffect(
|
|
|
19
19
|
agentID: string,
|
|
20
20
|
): Permission.Effect {
|
|
21
21
|
return rules.findLast((rule) =>
|
|
22
|
-
|
|
22
|
+
permissionMatches(rule.action, "subagent") && permissionMatches(rule.resource, agentID)
|
|
23
23
|
)?.effect ?? "ask";
|
|
24
24
|
}
|
|
25
25
|
|
package/src/threads.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { Permission } from "@opencode/schema/permission";
|
|
|
8
8
|
import { Session } from "@opencode/schema/session";
|
|
9
9
|
import { SessionMessage } from "@opencode/schema/session-message";
|
|
10
10
|
import { z } from "zod";
|
|
11
|
-
import { requireDelegation } from "./permissions";
|
|
11
|
+
import { permissionMatches, requireDelegation } from "./permissions";
|
|
12
12
|
import { Report, WorkerView } from "./rpc";
|
|
13
13
|
|
|
14
14
|
const sessionID = z.string().transform((value) => Session.ID.make(value));
|
|
@@ -48,6 +48,12 @@ export const Send = z
|
|
|
48
48
|
})
|
|
49
49
|
.strict();
|
|
50
50
|
export const WorkerTarget = z.object({ workerID: sessionID }).strict();
|
|
51
|
+
export const WorkflowWorker = z.object({
|
|
52
|
+
ownerID: sessionID,
|
|
53
|
+
runID: z.string().min(1),
|
|
54
|
+
stepKey: z.string().min(1),
|
|
55
|
+
callerAgent: z.string().min(1),
|
|
56
|
+
}).strict();
|
|
51
57
|
|
|
52
58
|
const digest = (parts: string[]) =>
|
|
53
59
|
createHash("sha256").update(JSON.stringify(parts)).digest("hex");
|
|
@@ -62,6 +68,24 @@ export const fingerprint = (input: z.infer<typeof Spawn>) =>
|
|
|
62
68
|
]);
|
|
63
69
|
|
|
64
70
|
const locks = new Map<string, Promise<void>>();
|
|
71
|
+
type WorkflowExecutionGrant = { watching: boolean };
|
|
72
|
+
const workflowState = globalThis as typeof globalThis & { __opWorkflowExecutionGrantsV2?: Map<string, WorkflowExecutionGrant> };
|
|
73
|
+
const workflowExecutionGrants = workflowState.__opWorkflowExecutionGrantsV2 ??= new Map<string, WorkflowExecutionGrant>();
|
|
74
|
+
export function authorizeWorkflowExecution(workerID: string) {
|
|
75
|
+
workflowExecutionGrants.set(workerID, { watching: false });
|
|
76
|
+
}
|
|
77
|
+
export function workflowExecutionAuthorized(workerID: string) {
|
|
78
|
+
return workflowExecutionGrants.has(workerID);
|
|
79
|
+
}
|
|
80
|
+
export function watchWorkflowExecution(workerID: string, untilIdle: () => Promise<void>) {
|
|
81
|
+
const grant = workflowExecutionGrants.get(workerID);
|
|
82
|
+
if (!grant || grant.watching) return;
|
|
83
|
+
grant.watching = true;
|
|
84
|
+
const revoke = () => {
|
|
85
|
+
if (workflowExecutionGrants.get(workerID) === grant) workflowExecutionGrants.delete(workerID);
|
|
86
|
+
};
|
|
87
|
+
void untilIdle().then(revoke, revoke);
|
|
88
|
+
}
|
|
65
89
|
export async function serialized<T>(
|
|
66
90
|
key: string,
|
|
67
91
|
run: () => Promise<T>,
|
|
@@ -119,6 +143,81 @@ export function threads(
|
|
|
119
143
|
return [...agent.data.permissions, ...(session.permissions ?? [])];
|
|
120
144
|
}
|
|
121
145
|
|
|
146
|
+
function workflowPermissions(inherited: Permission.Ruleset, readProfile?: Permission.Ruleset): Permission.Ruleset {
|
|
147
|
+
const readActions = ["read", "glob", "grep", "webfetch", "websearch", "skill", "external_directory"];
|
|
148
|
+
return [
|
|
149
|
+
...(readProfile === undefined ? [] : [
|
|
150
|
+
{ action: "*", resource: "*", effect: "deny" as const },
|
|
151
|
+
...readProfile.flatMap((rule) => readActions
|
|
152
|
+
.filter((action) => permissionMatches(rule.action, action))
|
|
153
|
+
.map((action) => ({ ...rule, action }))),
|
|
154
|
+
]),
|
|
155
|
+
...inherited.filter((rule) => rule.effect !== "allow").map((rule) => ({ ...rule, effect: "deny" as const })),
|
|
156
|
+
{ action: "subagent", resource: "*", effect: "deny" },
|
|
157
|
+
{ action: "threads_*", resource: "*", effect: "deny" },
|
|
158
|
+
{ action: "workflows_*", resource: "*", effect: "deny" },
|
|
159
|
+
{ action: "workflows_result", resource: "*", effect: "allow" },
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function prepareRole(
|
|
164
|
+
session: NativeSession,
|
|
165
|
+
link: z.infer<typeof Link>,
|
|
166
|
+
messageID: string,
|
|
167
|
+
callerAgent: string,
|
|
168
|
+
) {
|
|
169
|
+
if (await initialized(session, link)) return;
|
|
170
|
+
if (link.initialMessageID !== messageID || session.agent === undefined) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
"Worker initialization is pending. Retry the original dispatch request.",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const coordinator = await ctx.session.get({ sessionID: link.coordinatorID });
|
|
176
|
+
requireDelegation(
|
|
177
|
+
await callerPermissions(coordinator, callerAgent),
|
|
178
|
+
session.agent,
|
|
179
|
+
);
|
|
180
|
+
const agent = await ctx.agent.get({
|
|
181
|
+
agentID: session.agent,
|
|
182
|
+
location: session.location,
|
|
183
|
+
});
|
|
184
|
+
if (agent.data.model) {
|
|
185
|
+
await ctx.session.switchModel({
|
|
186
|
+
sessionID: session.id,
|
|
187
|
+
model: agent.data.model,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function journalReport(
|
|
193
|
+
session: NativeSession,
|
|
194
|
+
link: z.infer<typeof Link>,
|
|
195
|
+
input: z.infer<typeof Report>,
|
|
196
|
+
silent: boolean,
|
|
197
|
+
) {
|
|
198
|
+
let canonical = Report.parse(input);
|
|
199
|
+
if (!silent) {
|
|
200
|
+
const admitted = await ctx.session.synthetic({
|
|
201
|
+
sessionID: link.coordinatorID,
|
|
202
|
+
id: link.reportMessageID,
|
|
203
|
+
text: `Managed worker ${link.workerID} (${link.key}) report:\n${JSON.stringify(input)}`,
|
|
204
|
+
metadata: { opThreadsReport: input, workerID: link.workerID },
|
|
205
|
+
delivery: "queue",
|
|
206
|
+
resume: true,
|
|
207
|
+
});
|
|
208
|
+
canonical = Report.parse(admitted.payload.metadata?.opThreadsReport);
|
|
209
|
+
if (JSON.stringify(canonical) !== JSON.stringify(Report.parse(input))) {
|
|
210
|
+
throw new Error("This worker already has a different report. Start a new task with a new spawn key.");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const existing = await ctx.storage.get(reportKey(link));
|
|
214
|
+
if (existing !== undefined && JSON.stringify(Report.parse(existing)) !== JSON.stringify(canonical)) {
|
|
215
|
+
throw new Error("This worker already has a different report. Start a new task with a new spawn key.");
|
|
216
|
+
}
|
|
217
|
+
await ctx.storage.set(reportKey(link), canonical);
|
|
218
|
+
return canonical;
|
|
219
|
+
}
|
|
220
|
+
|
|
122
221
|
async function view(
|
|
123
222
|
session: NativeSession,
|
|
124
223
|
): Promise<z.infer<typeof WorkerView>> {
|
|
@@ -193,24 +292,24 @@ export function threads(
|
|
|
193
292
|
if (session.parentID !== undefined || recorded.workerID !== session.id) return;
|
|
194
293
|
const link = workerLink(session);
|
|
195
294
|
if (await initialized(session, link)) return;
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
295
|
+
await prepareRole(session, link, messageID, z.string().min(1).parse(callerAgent));
|
|
296
|
+
},
|
|
297
|
+
async prepareWorkflowPrompt(actor: string, messageID: string) {
|
|
298
|
+
const session = await ctx.session.get({ sessionID: actor });
|
|
299
|
+
const workflow = WorkflowWorker.safeParse(session.metadata?.opWorkflow);
|
|
300
|
+
if (!workflow.success) return;
|
|
301
|
+
const link = workerLink(session);
|
|
302
|
+
if (await initialized(session, link)) {
|
|
303
|
+
authorizeWorkflowExecution(actor);
|
|
304
|
+
return;
|
|
200
305
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
session.agent
|
|
205
|
-
|
|
206
|
-
const agent = await ctx.agent.get({
|
|
207
|
-
agentID: session.agent,
|
|
208
|
-
location: session.location,
|
|
209
|
-
});
|
|
210
|
-
if (agent.data.model) {
|
|
211
|
-
await ctx.session.switchModel({
|
|
306
|
+
await prepareRole(session, link, messageID, workflow.data.callerAgent);
|
|
307
|
+
if (!await initialized(session, link) && session.metadata?.opWorkflowAccess === "read") {
|
|
308
|
+
const coordinator = await ctx.session.get({ sessionID: link.coordinatorID });
|
|
309
|
+
const profile = await ctx.agent.get({ agentID: session.agent!, location: session.location });
|
|
310
|
+
await ctx.session.update({
|
|
212
311
|
sessionID: session.id,
|
|
213
|
-
|
|
312
|
+
permissions: workflowPermissions(await callerPermissions(coordinator, workflow.data.callerAgent), profile.data.permissions),
|
|
214
313
|
});
|
|
215
314
|
}
|
|
216
315
|
},
|
|
@@ -330,6 +429,81 @@ export function threads(
|
|
|
330
429
|
return view(await ctx.session.get({ sessionID: workerID }));
|
|
331
430
|
});
|
|
332
431
|
},
|
|
432
|
+
async spawnWorkflow(
|
|
433
|
+
actor: string,
|
|
434
|
+
input: z.infer<typeof Spawn>,
|
|
435
|
+
runtime: Pick<ToolContext, "agent"> & Pick<SessionContext, "model">,
|
|
436
|
+
workflow: z.infer<typeof WorkflowWorker> & { access: "read" | "write" },
|
|
437
|
+
) {
|
|
438
|
+
return serialized(actor, async () => {
|
|
439
|
+
const workflowMetadata = WorkflowWorker.parse({
|
|
440
|
+
ownerID: workflow.ownerID,
|
|
441
|
+
runID: workflow.runID,
|
|
442
|
+
stepKey: workflow.stepKey,
|
|
443
|
+
callerAgent: workflow.callerAgent,
|
|
444
|
+
});
|
|
445
|
+
const coordinator = await ctx.session.get({ sessionID: actor });
|
|
446
|
+
if (workflow.ownerID !== actor || workflow.callerAgent !== runtime.agent) {
|
|
447
|
+
throw new Error("Workflow worker ownership must be server-derived");
|
|
448
|
+
}
|
|
449
|
+
if (coordinator.parentID !== undefined || coordinator.metadata?.opThreads !== undefined) {
|
|
450
|
+
throw new Error("Native subagents and managed workers cannot start workflow workers");
|
|
451
|
+
}
|
|
452
|
+
if (!isAbsolute(input.directory) || !(await stat(input.directory)).isDirectory()) {
|
|
453
|
+
throw new Error("directory must be an existing absolute directory");
|
|
454
|
+
}
|
|
455
|
+
if (input.agent === undefined) throw new Error("Workflow workers require an explicit role agent");
|
|
456
|
+
const workerID = workerIdentity(actor, input.key);
|
|
457
|
+
let session = await ctx.session.get({ sessionID: workerID }).catch((error: unknown) => {
|
|
458
|
+
const missing = MissingSession.safeParse(error);
|
|
459
|
+
if (!missing.success || missing.data.sessionID !== workerID) throw error;
|
|
460
|
+
return undefined;
|
|
461
|
+
});
|
|
462
|
+
const proposed = Link.parse({
|
|
463
|
+
workerID,
|
|
464
|
+
coordinatorID: actor,
|
|
465
|
+
key: input.key,
|
|
466
|
+
fingerprint: fingerprint(input),
|
|
467
|
+
initialMessageID: SessionMessage.ID.create(),
|
|
468
|
+
reportMessageID: SessionMessage.ID.create(),
|
|
469
|
+
});
|
|
470
|
+
if (!session) {
|
|
471
|
+
const existing = await list(actor);
|
|
472
|
+
if (existing.filter((worker) => !worker.report && worker.outcome !== "failed" && worker.outcome !== "interrupted").length >= limit) {
|
|
473
|
+
throw new Error(`Coordinator worker limit reached (${limit}); wait for existing managed work before starting another workflow`);
|
|
474
|
+
}
|
|
475
|
+
const inherited = await callerPermissions(coordinator, runtime.agent);
|
|
476
|
+
requireDelegation(inherited, input.agent);
|
|
477
|
+
const restrictions = workflowPermissions(inherited, workflow.access === "read" ? [] : undefined);
|
|
478
|
+
session = await ctx.session.create({
|
|
479
|
+
id: workerID,
|
|
480
|
+
title: input.title,
|
|
481
|
+
location: { directory: input.directory },
|
|
482
|
+
agent: input.agent,
|
|
483
|
+
model: runtime.model,
|
|
484
|
+
permissions: restrictions,
|
|
485
|
+
metadata: { opThreads: proposed, opThreadsRole: true, opWorkflow: workflowMetadata, opWorkflowAccess: workflow.access },
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
const link = workerLink(session);
|
|
489
|
+
const recorded = WorkflowWorker.parse(session.metadata?.opWorkflow);
|
|
490
|
+
if (link.fingerprint !== proposed.fingerprint || JSON.stringify(recorded) !== JSON.stringify(workflowMetadata)) {
|
|
491
|
+
throw new Error("This workflow spawn identity belongs to a different request");
|
|
492
|
+
}
|
|
493
|
+
await ctx.storage.set(indexKey(link), link);
|
|
494
|
+
if (!await initialized(session, link)) {
|
|
495
|
+
await ctx.session.prompt({
|
|
496
|
+
sessionID: link.workerID,
|
|
497
|
+
id: link.initialMessageID,
|
|
498
|
+
delivery: "queue",
|
|
499
|
+
metadata: { opThreadsCallerAgent: runtime.agent, opWorkflowRunID: workflow.runID },
|
|
500
|
+
text: `${input.task}\n\nYou are a leaf workflow worker. Do not delegate, spawn or control other workers, or operate workflow controls. Work only in ${input.directory}. Finish by submitting one accepted workflows_result with a verdict, concise summary, evidence, and a result matching the requested JSON schema. If validation rejects your result, correct it and resubmit. Native completion without that validated report is not success.`,
|
|
501
|
+
});
|
|
502
|
+
await ctx.storage.set(initializedKey(link), true);
|
|
503
|
+
}
|
|
504
|
+
return view(await ctx.session.get({ sessionID: workerID }));
|
|
505
|
+
});
|
|
506
|
+
},
|
|
333
507
|
async send(actor: string, input: z.infer<typeof Send>) {
|
|
334
508
|
const { session, link } = await owned(actor, input.workerID);
|
|
335
509
|
if (!await initialized(session, link)) {
|
|
@@ -338,6 +512,7 @@ export function threads(
|
|
|
338
512
|
const id = SessionMessage.ID.make(
|
|
339
513
|
`msg_${digest([input.workerID, "send", input.key]).slice(0, 32)}`,
|
|
340
514
|
);
|
|
515
|
+
if (session.metadata?.opWorkflow !== undefined) authorizeWorkflowExecution(input.workerID);
|
|
341
516
|
const admitted = await ctx.session.synthetic({
|
|
342
517
|
sessionID: input.workerID,
|
|
343
518
|
id,
|
|
@@ -365,22 +540,16 @@ export function threads(
|
|
|
365
540
|
async report(actor: string, input: z.infer<typeof Report>) {
|
|
366
541
|
const session = await ctx.session.get({ sessionID: actor });
|
|
367
542
|
const link = workerLink(session);
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
id: link.reportMessageID,
|
|
371
|
-
text: `Managed worker ${link.workerID} (${link.key}) report:\n${JSON.stringify(input)}`,
|
|
372
|
-
metadata: { opThreadsReport: input, workerID: link.workerID },
|
|
373
|
-
delivery: "queue",
|
|
374
|
-
resume: true,
|
|
375
|
-
});
|
|
376
|
-
const canonical = Report.parse(
|
|
377
|
-
admitted.payload.metadata?.opThreadsReport,
|
|
378
|
-
);
|
|
379
|
-
await ctx.storage.set(reportKey(link), canonical);
|
|
380
|
-
if (JSON.stringify(canonical) !== JSON.stringify(input)) {
|
|
381
|
-
throw new Error("This worker already has a different report. Start a new task with a new spawn key.");
|
|
382
|
-
}
|
|
543
|
+
if (session.metadata?.opWorkflow !== undefined) throw new Error("Workflow workers must call workflows_result");
|
|
544
|
+
const canonical = await journalReport(session, link, input, false);
|
|
383
545
|
return { workerID: link.workerID, report: canonical };
|
|
384
546
|
},
|
|
547
|
+
async reportWorkflow(actor: string, input: z.infer<typeof Report>) {
|
|
548
|
+
const session = await ctx.session.get({ sessionID: actor });
|
|
549
|
+
WorkflowWorker.parse(session.metadata?.opWorkflow);
|
|
550
|
+
const link = workerLink(session);
|
|
551
|
+
const report = { verdict: input.verdict, summary: input.summary, evidence: input.evidence };
|
|
552
|
+
return { workerID: link.workerID, report: await journalReport(session, link, report, true) };
|
|
553
|
+
},
|
|
385
554
|
};
|
|
386
555
|
}
|