Repository navigation
feat(dev): add npm run dev:stack one-command source stack (#65) #305
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
43bf621
404be5e
93f0498
3cc821c
d9dd7fc
b96ccb3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { childTraceparent, createRequestTraceContext, requestTraceContexts, traceparentForTraceId, withTurnTraceparent, workflowCorrelationMemo } from "../trace-context.js"; | ||
| import { parseTraceparent } from "@space/contracts"; | ||
|
|
||
| describe("trace context propagation", () => { | ||
| it("creates a W3C trace context from scratch", () => { | ||
| const context = createRequestTraceContext(undefined); | ||
| expect(context.w3cTraceId).toMatch(/^[0-9a-f]{32}$/); | ||
| expect(context.spanId).toMatch(/^[0-9a-f]{16}$/); | ||
| expect(context.parentSpanId).toBeNull(); | ||
| expect(context.inbound).toBe(false); | ||
| expect(parseTraceparent(context.traceparent)).not.toBeNull(); | ||
| }); | ||
|
|
||
| it("creates a child traceparent sharing the trace id with new span", () => { | ||
| const context = createRequestTraceContext(undefined); | ||
| const child = childTraceparent(context.traceparent); | ||
| expect(child).not.toBeNull(); | ||
| const parsed = parseTraceparent(child!); | ||
| expect(parsed!.traceId).toBe(context.w3cTraceId); | ||
| expect(parsed!.parentId).not.toBe(context.spanId); | ||
| }); | ||
|
|
||
| it("traceparentForTraceId returns null for unknown request id", () => { | ||
| expect(traceparentForTraceId("req:unknown")).toBeNull(); | ||
| }); | ||
|
|
||
| it("traceparentForTraceId returns child traceparent for known request id", () => { | ||
| const context = createRequestTraceContext(undefined); | ||
| requestTraceContexts.remember("req:test-1", context); | ||
| const child = traceparentForTraceId("req:test-1"); | ||
| expect(child).not.toBeNull(); | ||
| const parsed = parseTraceparent(child!); | ||
| expect(parsed!.traceId).toBe(context.w3cTraceId); | ||
| expect(parsed!.parentId).not.toBe(context.spanId); | ||
| }); | ||
|
|
||
| it("workflowCorrelationMemo encodes traceId and traceparent", () => { | ||
| const context = createRequestTraceContext(undefined); | ||
| const memo = workflowCorrelationMemo({ traceId: "req:test-1", traceparent: context.traceparent }); | ||
| expect(memo.spaceTraceId).toBe("req:test-1"); | ||
| expect(memo.traceparent).toBe(context.traceparent); | ||
| expect(memo.w3cTraceId).toBe(context.w3cTraceId); | ||
| }); | ||
|
|
||
| it("workflowCorrelationMemo omits unset fields", () => { | ||
| const memo = workflowCorrelationMemo({ traceId: null }); | ||
| expect(memo.spaceTraceId).toBeUndefined(); | ||
| expect(memo.traceparent).toBeUndefined(); | ||
| expect(memo.w3cTraceId).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("workflowCorrelationMemo filters invalid traceId", () => { | ||
| const memo = workflowCorrelationMemo({ traceId: "invalid id with spaces" }); | ||
| expect(memo.spaceTraceId).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("withTurnTraceparent adds traceparent when missing", () => { | ||
| const context = createRequestTraceContext(undefined); | ||
| requestTraceContexts.remember("req:test-2", context); | ||
| const turn = { traceId: "req:test-2" }; | ||
| const result = withTurnTraceparent(turn); | ||
| const parsed = parseTraceparent(result.traceparent!); | ||
| expect(parsed!.traceId).toBe(context.w3cTraceId); | ||
| expect(parsed!.parentId).not.toBe(context.spanId); | ||
| }); | ||
|
|
||
| it("withTurnTraceparent keeps existing valid traceparent", () => { | ||
| const context = createRequestTraceContext(undefined); | ||
| const turn = { traceId: "req:any", traceparent: context.traceparent }; | ||
| const result = withTurnTraceparent(turn); | ||
| expect(result.traceparent).toBe(context.traceparent); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ import { | |
| } from "@space/contracts"; | ||
| import { SpaceConflictError, SpaceNotFoundError, type SpaceStore } from "@space/runtime"; | ||
| import type { DotGoalsRepository, DotsRepository } from "@space/db"; | ||
| import { traceparentForTraceId, workflowCorrelationMemo } from "./trace-context.js"; | ||
|
|
||
| /** | ||
| * Space Dots Stage 4: Goals. | ||
|
|
@@ -26,10 +27,10 @@ import type { DotGoalsRepository, DotsRepository } from "@space/db"; | |
| * blocking question, cancellation, three consecutive failures or the budget. | ||
| */ | ||
| export interface DotGoalWorkflowCoordinator { | ||
| start(goalId: string): Promise<void>; | ||
| wake(goalId: string): Promise<void>; | ||
| start(goalId: string, traceId?: string | null, traceparent?: string | null): Promise<void>; | ||
| wake(goalId: string, traceId?: string | null, traceparent?: string | null): Promise<void>; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Forward trace context when waking a Goal. When a user resumes or steers a Goal, Lines 153 and 164 pass that request's trace context to
|
||
| /** Starts the Goal's workflow when none is running; true when it had to start one. */ | ||
| ensure?(goalId: string): Promise<boolean>; | ||
| ensure?(goalId: string, traceId?: string | null, traceparent?: string | null): Promise<boolean>; | ||
| } | ||
| export class TemporalDotGoalWorkflowCoordinator implements DotGoalWorkflowCoordinator { | ||
| constructor(private readonly options: { address: string; namespace: string; taskQueue: string }) {} | ||
|
|
@@ -38,25 +39,27 @@ export class TemporalDotGoalWorkflowCoordinator implements DotGoalWorkflowCoordi | |
| try { return await action(new Client({ connection, namespace: this.options.namespace })); } | ||
| finally { await connection.close(); } | ||
| } | ||
| start(goalId: string) { | ||
| start(goalId: string, traceId?: string | null, traceparent?: string | null) { | ||
| return this.client(async (client) => { | ||
| try { | ||
| await client.workflow.start("dotGoalWorkflow", { workflowId: dotGoalWorkflowId(goalId), taskQueue: this.options.taskQueue, | ||
| args: [{ goalId }], workflowIdReusePolicy: "REJECT_DUPLICATE" }); | ||
| args: [{ goalId, traceparent }], workflowIdReusePolicy: "REJECT_DUPLICATE", | ||
| memo: workflowCorrelationMemo({ traceId, traceparent }) }); | ||
| } catch (error) { if (!(error instanceof WorkflowExecutionAlreadyStartedError)) throw error; } | ||
| }); | ||
| } | ||
| wake(goalId: string) { | ||
| wake(goalId: string, traceId?: string | null, traceparent?: string | null) { | ||
| return this.client(async (client) => { | ||
| try { await client.workflow.getHandle(dotGoalWorkflowId(goalId)).signal(DOT_GOAL_SIGNAL); } | ||
| try { await client.workflow.getHandle(dotGoalWorkflowId(goalId)).signal(DOT_GOAL_SIGNAL, { traceId, traceparent }); } | ||
| catch (error) { if (!(error instanceof WorkflowNotFoundError)) throw error; } | ||
| }); | ||
| } | ||
| ensure(goalId: string) { | ||
| ensure(goalId: string, traceId?: string | null, traceparent?: string | null) { | ||
| return this.client(async (client) => { | ||
| try { | ||
| await client.workflow.start("dotGoalWorkflow", { workflowId: dotGoalWorkflowId(goalId), taskQueue: this.options.taskQueue, | ||
| args: [{ goalId }], workflowIdReusePolicy: "ALLOW_DUPLICATE" }); | ||
| args: [{ goalId, traceparent }], workflowIdReusePolicy: "ALLOW_DUPLICATE", | ||
| memo: workflowCorrelationMemo({ traceId, traceparent }) }); | ||
| return true; | ||
| } catch (error) { | ||
| if (error instanceof WorkflowExecutionAlreadyStartedError) return false; | ||
|
|
@@ -125,7 +128,7 @@ export function createDotGoalService(deps: DotGoalServiceDeps) { | |
| if ((error as { code?: string }).code === "DOT_GOAL_OPEN") throw new DotGoalError(409, "DOT_GOAL_OPEN", "This conversation already has an open Goal."); | ||
| throw error; | ||
| } | ||
| try { await deps.workflow.start(goal.goalId); } | ||
| try { await deps.workflow.start(goal.goalId, request?.id ?? null, traceparentForTraceId(request?.id)); } | ||
| catch { | ||
| goal = await finish(goal, "FAILED", "The Goal runtime could not start. Try again."); | ||
| throw new DotGoalError(503, "DOT_GOAL_RUNTIME", "The Goal runtime could not start. Try again."); | ||
|
|
@@ -147,7 +150,7 @@ export function createDotGoalService(deps: DotGoalServiceDeps) { | |
| const run = session ? await deps.store.getLatestSpaceAgentRun(session.sessionId) : null; | ||
| if (session && run && (run.status === "QUEUED" || run.status === "RUNNING")) await deps.interrupt(session.paneId, "Goal cancelled by the user.").catch(() => undefined); | ||
| } | ||
| await deps.workflow?.wake(goalId).catch(() => undefined); | ||
| await deps.workflow?.wake(goalId, request?.id ?? null, traceparentForTraceId(request?.id)).catch(() => undefined); | ||
| await deps.audit(request, "dot.goal.control", goal.roomId, { dotId, goalId, action }); | ||
| return next; | ||
| } | ||
|
|
@@ -158,7 +161,7 @@ export function createDotGoalService(deps: DotGoalServiceDeps) { | |
| if (!goal) throw new DotGoalError(404, "DOT_GOAL_NOT_FOUND", "Goal was not found."); | ||
| const next = await deps.goals.addSteering(goalId, text); | ||
| if (!next) throw new DotGoalError(409, "DOT_GOAL_STATE", "Guidance can be added only to an open Goal, up to 50 notes."); | ||
| await deps.workflow?.wake(goalId).catch(() => undefined); | ||
| await deps.workflow?.wake(goalId, request?.id ?? null, traceparentForTraceId(request?.id)).catch(() => undefined); | ||
| await deps.audit(request, "dot.goal.steer", goal.roomId, { dotId, goalId }); | ||
| return next; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔎 Supported by static analysis
Script executed:
Repository: oll4com/spaceapp
Length of output: 3079
Script executed:
Repository: oll4com/spaceapp
Length of output: 12100
Build internal packages as part of
dev:stack.The section says that
npm run dev:stackworks afternpm ci, but later requiresnpm run build.scripts/dev-stack.mjsruns migrations and starts host processes without running that build. Addnpm run buildbefore migrations to preserve the documented one-command workflow. This is a localized fix, so the effort classification should be low rather than high.