@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.
@@ -1,6 +1,6 @@
1
1
  import type { Permission } from "@opencode/schema/permission";
2
2
 
3
- function matches(pattern: string, value: string) {
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
- matches(rule.action, "subagent") && matches(rule.resource, agentID)
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
- if (link.initialMessageID !== messageID || session.agent === undefined) {
197
- throw new Error(
198
- "Worker initialization is pending. Retry the original threads_spawn request.",
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
- const coordinator = await ctx.session.get({ sessionID: link.coordinatorID });
202
- requireDelegation(
203
- await callerPermissions(coordinator, z.string().min(1).parse(callerAgent)),
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
- model: agent.data.model,
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
- const admitted = await ctx.session.synthetic({
369
- sessionID: link.coordinatorID,
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
  }