Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
110 commits
Select commit Hold shift + click to select a range
e15d56e
docs: outline non-turn-based surface problem
omegent-app[bot] Jul 31, 2026
08cf077
docs: develop NTBS event execution proposal
omegent-app[bot] Aug 4, 2026
5e81c76
chore: defined processing
enricopolanski Aug 4, 2026
66fc816
chore: planning of ntsb processing
enricopolanski Aug 4, 2026
2520ce9
chore: update ntsb planning
enricopolanski Aug 4, 2026
fb35e27
chore: planning ntsb output
enricopolanski Aug 5, 2026
b60d8bd
chore: more processing
enricopolanski Aug 5, 2026
f076f9c
feat: finish processing
enricopolanski Aug 5, 2026
0315ca4
chore: kickstart architecture document
enricopolanski Aug 5, 2026
bb095fb
chore: settle on generic definition
enricopolanski Aug 5, 2026
2e8830b
chore: document lifecycle
enricopolanski Aug 5, 2026
a484593
feat: define ntbs architeture
enricopolanski Aug 5, 2026
4f0ac10
feat: rename ntsb -> ntbs
enricopolanski Aug 5, 2026
ce4864d
fix: ntsb -> ntbs
enricopolanski Aug 5, 2026
cacc425
feat: write plan
enricopolanski Aug 6, 2026
c6ff7a6
feat: implement basic lifecycle types
enricopolanski Aug 7, 2026
d8f51d0
feat: implement adapter context service
enricopolanski Aug 7, 2026
e240d6a
feat: work on processor
enricopolanski Aug 7, 2026
06aa6a0
feat: processor types
enricopolanski Aug 7, 2026
b9ddcf0
add inbout and outbound processor function
enricopolanski Aug 7, 2026
3050eae
feat: add makeProcessor declaration
enricopolanski Aug 7, 2026
1cb5aaa
feat: more ntbs processor work
enricopolanski Aug 7, 2026
693acf2
feat: document ntbs processor declarations
enricopolanski Aug 7, 2026
1cc1ce3
fix: ntbs processor and schemas flows
enricopolanski Aug 7, 2026
2cf54d5
fix: docs in ntbs processor
enricopolanski Aug 7, 2026
0f2678f
chore: complete abstract/declaration phase
enricopolanski Aug 8, 2026
7cc3399
chore: fixes part 1
enricopolanski Aug 8, 2026
06d98fd
feat: review removal of the requestaccepted lifecycle event
enricopolanski Aug 8, 2026
c3c22fd
chore: finish reviewing first 4 points of adversarial review
enricopolanski Aug 8, 2026
d04c510
chore: finalize review
enricopolanski Aug 8, 2026
2c03c90
feat: write adversarial review
enricopolanski Aug 9, 2026
7b7e776
chore: document concurrency of NTBSProcessor
enricopolanski Aug 9, 2026
a1dfae8
feat: more updates and refactors of ntbs
enricopolanski Aug 9, 2026
18f012b
feat: more ntbs fixes
enricopolanski Aug 9, 2026
bf9662c
fix #7 of adversarial review
enricopolanski Aug 9, 2026
84a5da4
feat: add or note all fixes
enricopolanski Aug 9, 2026
4b2a545
feat: start working on processor
enricopolanski Aug 9, 2026
5b35c02
chore: remove adapter.accept, not really needed
enricopolanski Aug 9, 2026
69ff904
chore: continue work on processor
enricopolanski Aug 9, 2026
264eac7
feat: processor, implement adversarial review feedback
enricopolanski Aug 10, 2026
ac1c569
feat: added soft concurrency lock
enricopolanski Aug 10, 2026
2bc10c2
feat: create threadstarted in processor
enricopolanski Aug 10, 2026
20716ad
feat: implement start t3 turn
enricopolanski Aug 10, 2026
f381b2c
feat: implement get progression logic
enricopolanski Aug 11, 2026
be64e45
chore: more work on monitor
enricopolanski Aug 11, 2026
fb67c56
docs: monitor
enricopolanski Aug 11, 2026
2b862f6
fix: turn and message consistency
enricopolanski Aug 11, 2026
0011c95
chore: retry retriable stuff
enricopolanski Aug 11, 2026
07c6f15
feat: more work on locks and interrupts
enricopolanski Aug 12, 2026
4a2b3df
chore: processor v1
enricopolanski Aug 12, 2026
5d99cee
feat: start processor testing
enricopolanski Aug 12, 2026
35227a6
chore: more processor tests
enricopolanski Aug 12, 2026
9debc39
chore: first step of processor testing
enricopolanski Aug 13, 2026
3b6e6c6
chore: add deferred idea to ideas.md
enricopolanski Aug 13, 2026
d5e037b
feat: simplify adapter
enricopolanski Aug 13, 2026
509e655
chore: add new idea
enricopolanski Aug 13, 2026
829b9b6
chore: update processor to new api
enricopolanski Aug 13, 2026
75e6521
feat: more work on ntbs test helpers
enricopolanski Aug 13, 2026
f2179ae
chore: sourceUris improvements
enricopolanski Aug 13, 2026
4b71c96
fix: typescript stuff
enricopolanski Aug 14, 2026
d15a33e
docs: review NTBS processor refinements
enricopolanski Aug 14, 2026
8786971
chore: claude code review
enricopolanski Aug 14, 2026
11c8c9d
test: note missing-turn recovery coverage
enricopolanski Aug 14, 2026
ad76111
refactor: simplify NTBS turn monitoring
enricopolanski Aug 14, 2026
012f0ed
chore: remove pointless recordedAt
enricopolanski Aug 14, 2026
4f951ed
feat: simplify turn status
enricopolanski Aug 14, 2026
7136a61
chore: simplify resolveT3Outcome
enricopolanski Aug 14, 2026
7b728e9
chore: simplify acknowledgement
enricopolanski Aug 14, 2026
0e5b676
chore: refactor process
enricopolanski Aug 14, 2026
7014ba1
chore: implement feedback
enricopolanski Aug 15, 2026
dc0f90c
docs: update NTBS processor review
enricopolanski Aug 15, 2026
e67dc48
docs: scope pull request handoff rules
enricopolanski Aug 15, 2026
fa9833b
chore: remove all the monitoring crap
enricopolanski Aug 15, 2026
a70a0aa
chore: refactor
enricopolanski Aug 15, 2026
eeeca85
chore: remove stale/wrong todo
enricopolanski Aug 15, 2026
68ae88f
docs(ntbs): refresh processor reviews
enricopolanski Aug 15, 2026
3976de8
chore: renames and refactors
enricopolanski Aug 16, 2026
a3324f6
feat: continue refactoring
enricopolanski Aug 16, 2026
b245310
feat: refine next phases of work
enricopolanski Aug 16, 2026
87f2489
chore: start refactoring the ntbs state
enricopolanski Aug 16, 2026
9620fd7
chore: bump ntbs todos
enricopolanski Aug 17, 2026
7bb0177
feat: continue refactor of exchange
enricopolanski Aug 17, 2026
5d61152
feat: implement exchange.ts
enricopolanski Aug 18, 2026
3feccf6
feat: complete exchange refactor and its tests
enricopolanski Aug 18, 2026
83945a3
feat: update exchange.ts, implement exchangerepository
enricopolanski Aug 19, 2026
60ec43f
feat: update adapter and exchangerepository
enricopolanski Aug 21, 2026
a8ae9fd
feat: update adapter
enricopolanski Aug 21, 2026
3a298d0
feat: t3gateway core type implementation
enricopolanski Aug 24, 2026
4f64b36
chore: update ntbs docs
enricopolanski Aug 24, 2026
0240582
chore: bump
enricopolanski Aug 24, 2026
b383ec8
bump
enricopolanski Aug 24, 2026
e36e9ae
refactor(ntbs): single adapter tag, rewrite processor docs
enricopolanski Aug 24, 2026
ba2f39b
chore: align tags
enricopolanski Aug 24, 2026
045cdbd
chore: refactor the processor
enricopolanski Aug 24, 2026
efd3468
update
enricopolanski Aug 24, 2026
08b6be4
feat: start work
enricopolanski Aug 24, 2026
4ff48db
chore: bump processor with a lock
enricopolanski Aug 24, 2026
26bb942
process refactor
enricopolanski Aug 24, 2026
c63a1a4
chore: update tests to use baseRefSha
enricopolanski Aug 24, 2026
72c58d6
chore: naming clean up
enricopolanski Aug 24, 2026
af1ed76
chore: update processor
enricopolanski Aug 24, 2026
47802fd
fix: type issue
enricopolanski Aug 24, 2026
46ac2e6
feat: write processor new tests
enricopolanski Aug 24, 2026
7788299
feat: bump todo
enricopolanski Aug 25, 2026
a2ed992
chore: refine t3 gateway apis
enricopolanski Aug 25, 2026
19cb40c
feat: implement planCoordinates
enricopolanski Aug 25, 2026
ee69990
feat: handle edge cases in t3gateway planCoordinates
enricopolanski Aug 26, 2026
ce43854
chore: bump tests
enricopolanski Aug 26, 2026
0c31d23
chore: updates
enricopolanski Aug 27, 2026
a9d6bcc
feat: implement first successful test of t3gateway
enricopolanski Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@
},
"search.exclude": {
".repos/**": true
}
},
"js/ts.experimental.useTsgo": true
}
9 changes: 7 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,14 @@ whole branching model. See
work dir (`~/.t3/compose-work`, not tmpfs `/tmp`) before install. See
[docs/fork-stack.md](./docs/fork-stack.md) ("Integration overlay compose and lockfiles").

## Pull requests (required handoff)
## Pull requests (when publishing)

When implementation work for a user request is done (code, docs, config — not pure Q&A):
Do not commit, rebase, push, or open/update a PR merely because an edit is complete. Do those
things only when the user explicitly requests publication or the specific version-control action,
or when another workflow in this file explicitly requires it (for example, Discord-originated
work). A request to change code, docs, or config does not by itself authorize publication.

When publication or a PR handoff is in scope:

1. **Commit** the changes on a feature branch cut from `fork/dev`.
2. **Open or update a PR against `fork/dev`** before handing off — for every kind of work, including
Expand Down
166 changes: 166 additions & 0 deletions apps/server/src/ntbs/ExchangeRepository.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect, Exit } from "effect";
import { MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
import {
ExchangeRepositoryError,
ExchangeRepository,
inMemoryExchangeRepository,
} from "./ExchangeRepository.ts";
import {
makeRequestClaimed,
toReplyPending,
toReplyPosted,
toThreadCreated,
toUndeliverable,
} from "./exchange.ts";

const makeExchange = (sourceUri: string, threadId: string) =>
makeRequestClaimed(
{
sourceUri,
snapshot: "request",
attachments: [],
},
{
projectId: ProjectId.make("project"),
startBranchName: "main",
startCommitSha: "start-commit-sha",
threadId: ThreadId.make(threadId),
userMessageId: MessageId.make(`message-${threadId}`),
worktreeBranchName: `branch-${threadId}`,
},
);

describe("inMemoryExchangeRepository", () => {
it.layer(inMemoryExchangeRepository)((it) => {
it.effect("allows the same sourceUri to replace its state", () =>
Effect.gen(function* () {
const repository = yield* ExchangeRepository;
const claimed = makeExchange("test://request/1", "thread-1");
const threadCreated = toThreadCreated(claimed);

yield* repository.upsert(claimed);
yield* repository.upsert(threadCreated);

expect(yield* repository.findBySourceUri(claimed.sourceUri)).toEqual(threadCreated);
}),
);
});

it.layer(inMemoryExchangeRepository)((it) => {
it.effect("rejects a threadId already owned by another sourceUri", () =>
Effect.gen(function* () {
const repository = yield* ExchangeRepository;
const existing = makeExchange("test://request/1", "shared-thread");
const conflicting = makeExchange("test://request/2", "shared-thread");

yield* repository.upsert(existing);
const error = yield* Effect.flip(repository.upsert(conflicting));

expect(error).toBeInstanceOf(ExchangeRepositoryError);
expect(error.reason).toContain(existing.t3.threadId);
expect(yield* repository.findBySourceUri(existing.sourceUri)).toEqual(existing);
expect(yield* repository.findBySourceUri(conflicting.sourceUri)).toBeNull();
}),
);
});

it.layer(inMemoryExchangeRepository)((it) => {
it.effect("finds an exchange by threadId", () =>
Effect.gen(function* () {
const repository = yield* ExchangeRepository;
const exchange = makeExchange("test://request/1", "thread-1");

yield* repository.upsert(exchange);

expect(yield* repository.findByThreadId(exchange.t3.threadId)).toEqual(exchange);
expect(yield* repository.findByThreadId(ThreadId.make("unknown-thread"))).toBeNull();
}),
);
});

it.layer(inMemoryExchangeRepository)((it) => {
it.effect("finds only non-terminal exchanges", () =>
Effect.gen(function* () {
const repository = yield* ExchangeRepository;
const claimed = makeExchange("test://request/claimed", "thread-claimed");
const threadCreated = toThreadCreated(
makeExchange("test://request/thread-created", "thread-created"),
);
const replyPending = toReplyPending(
toThreadCreated(makeExchange("test://request/reply-pending", "thread-reply-pending")),
{ type: "answer", text: "pending reply" },
);
const replyPosted = toReplyPosted(
toReplyPending(
toThreadCreated(makeExchange("test://request/reply-posted", "thread-reply-posted")),
{ type: "answer", text: "posted reply" },
),
"test://reply/posted",
);
const undeliverable = toUndeliverable(
toReplyPending(
toThreadCreated(makeExchange("test://request/undeliverable", "thread-undeliverable")),
{ type: "failure", text: "undeliverable reply", cause: "undeliverable te dico" },
),
{ message: "platform rejected the reply" },
);

yield* Effect.forEach(
[claimed, threadCreated, replyPending, replyPosted, undeliverable],
repository.upsert,
);

const results = yield* repository.findNonTerminalExchanges;

expect(results).toHaveLength(3);
expect(results).toEqual(expect.arrayContaining([claimed, threadCreated, replyPending]));
}),
);
});

it.layer(inMemoryExchangeRepository)((it) => {
it.effect("preserves existing records when a replacement has a conflicting threadId", () =>
Effect.gen(function* () {
const repository = yield* ExchangeRepository;
const first = makeExchange("test://request/1", "thread-1");
const second = makeExchange("test://request/2", "thread-2");
const conflictingReplacement = makeExchange("test://request/2", "thread-1");

yield* repository.upsert(first);
yield* repository.upsert(second);
yield* Effect.flip(repository.upsert(conflictingReplacement));

expect(yield* repository.findBySourceUri(first.sourceUri)).toEqual(first);
expect(yield* repository.findBySourceUri(second.sourceUri)).toEqual(second);
expect(yield* repository.findByThreadId(first.t3.threadId)).toEqual(first);
expect(yield* repository.findByThreadId(second.t3.threadId)).toEqual(second);
}),
);
});

it.layer(inMemoryExchangeRepository)((it) => {
it.effect("atomically rejects concurrent upserts with the same threadId", () =>
Effect.gen(function* () {
const repository = yield* ExchangeRepository;
const first = makeExchange("test://request/1", "shared-thread");
const second = makeExchange("test://request/2", "shared-thread");

const outcomes = yield* Effect.all(
[Effect.exit(repository.upsert(first)), Effect.exit(repository.upsert(second))],
{ concurrency: "unbounded" },
);

expect(outcomes.filter(Exit.isSuccess)).toHaveLength(1);
expect(outcomes.filter(Exit.isFailure)).toHaveLength(1);

const stored = yield* Effect.all([
repository.findBySourceUri(first.sourceUri),
repository.findBySourceUri(second.sourceUri),
]);

expect(stored.filter((state) => state !== null)).toHaveLength(1);
}),
);
});
});
111 changes: 111 additions & 0 deletions apps/server/src/ntbs/ExchangeRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* Defines the repository for durable NTBS exchanges.
*
* An exchange links an admitted external-platform request to its planned T3
* work and tracks its progress through delivery of the eventual reply.
*
* The repository owns persistence, lookup, and recovery. Each stored exchange
* is identified by its `sourceUri`, while the processor decides how to handle
* duplicate requests. It does not communicate with T3 or the originating
* platform.
*/
import { Array, Effect, Context, Data, HashMap, Ref, Layer } from "effect";
import { isNonTerminal, type Exchange, type NonTerminalExchange } from "./exchange.ts";
import type { ThreadId } from "@t3tools/contracts";
import { isSome } from "effect/Option";

export class ExchangeRepositoryError extends Data.TaggedError("ExchangeRepositoryError")<{
readonly reason: string;
readonly cause: unknown;
}> {}

export interface ExchangeRepository {
readonly findBySourceUri: (
sourceUri: string,
) => Effect.Effect<Exchange | null, ExchangeRepositoryError>;

readonly findByThreadId: (
threadId: ThreadId,
) => Effect.Effect<Exchange | null, ExchangeRepositoryError>;

readonly findNonTerminalExchanges: Effect.Effect<
ReadonlyArray<NonTerminalExchange>,
ExchangeRepositoryError
>;

/** Inserts or replaces the exchange identified by its `sourceUri`. */
readonly upsert: (exchange: Exchange) => Effect.Effect<void, ExchangeRepositoryError>;
}

export const ExchangeRepository = Context.Service<ExchangeRepository>(
"t3code/ntbs/ExchangeRepository",
);

const inMemoryER: Effect.Effect<ExchangeRepository> = Effect.gen(function* () {
const exchanges: Ref.Ref<HashMap.HashMap<string, Exchange>> = yield* Ref.make(
HashMap.empty<string, Exchange>(),
);

const upsert = Effect.fn("ExchangeRepository.upsert")(function* (exchange: Exchange) {
// we return conflicting source Uri as the first argument
// in case we find that the same threadId belongs already to a different sourceUri
const conflictingSourceUri = yield* Ref.modify(exchanges, (map) => {
const conflict = HashMap.findFirst(
map,
(existing, sourceUri) =>
sourceUri !== exchange.sourceUri && existing.t3.threadId === exchange.t3.threadId,
);

return isSome(conflict)
? [conflict.value[0], map]
: [null, HashMap.set(map, exchange.sourceUri, exchange)];
});

if (conflictingSourceUri !== null) {
return yield* new ExchangeRepositoryError({
reason: `Thread ${exchange.t3.threadId} already belongs to exchange ${conflictingSourceUri}`,
cause: {
threadId: exchange.t3.threadId,
existingSourceUri: conflictingSourceUri,
incomingSourceUri: exchange.sourceUri,
},
});
}
});

const findBySourceUri = (uri: string) =>
Ref.get(exchanges).pipe(
Effect.map((map) => HashMap.get(map, uri)),
Effect.map((o) => (isSome(o) ? o.value : null)),
);

const findByThreadId = (threadId: ThreadId) =>
Ref.get(exchanges).pipe(
Effect.map((map) => HashMap.filter(map, (val) => val.t3.threadId === threadId)),
// if we get more than one Exchange in the HashMap, something's wrong
Effect.andThen((map) =>
HashMap.size(map) > 1
? new ExchangeRepositoryError({
reason: "Exchange Repository contains more than one entry for thredId: " + threadId,
cause: map,
})
: Effect.succeed(Array.fromIterable(HashMap.entries(map))).pipe(
Effect.map((arr) => (arr.length === 1 ? arr[0]![1] : null)),
),
),
);

const findNonTerminalExchanges = Ref.get(exchanges).pipe(
Effect.map((map) => Array.fromIterable(HashMap.entries(map))),
Effect.map((arr) =>
Array.filter(
arr.map((el) => el[1]),
isNonTerminal,
),
),
);

return { upsert, findBySourceUri, findByThreadId, findNonTerminalExchanges };
});

export const inMemoryExchangeRepository = Layer.effect(ExchangeRepository, inMemoryER);
56 changes: 56 additions & 0 deletions apps/server/src/ntbs/adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { ReplyPending, ThreadCreated, UndeliverableCause } from "./exchange.ts";
import { Context, Data, Effect } from "effect";

/**
* A platform operation failed without establishing that reply delivery is
* permanently impossible. The processor may retry the operation later.
*/
export class AdapterError extends Data.TaggedError("AdapterError")<{
readonly reason: string;
readonly cause: unknown;
}> {}

/** The platform definitively rejected delivery of a pending reply. */
export class ReplyRejected extends Data.TaggedError("ReplyRejected")<{
readonly cause: UndeliverableCause;
}> {}

/**
* Defines the platform-specific operations used by the shared NTBS processor.
*
* An adapter communicates with one originating platform. It posts
* acknowledgements and replies, and can discover whether a particular pending
* reply was already posted. It does not persist exchange state, create T3
* threads, or interpret T3 events.
*/
export interface NTBSAdapter {
/**
* Posts a best-effort working acknowledgement for an exchange whose T3
* thread now exists. The acknowledgement is not part of the durable exchange
* lifecycle and its platform identifier is not retained.
*/
readonly acknowledge: (state: ThreadCreated) => Effect.Effect<void, AdapterError>;

/**
* Posts the exact reply stored in `state` to the destination identified by
* its `sourceUri`.
*
* Returns an adapter-encoded URI locating the posted reply. `ReplyRejected`
* means the platform definitively refused delivery; other failures remain
* retryable.
*/
readonly postReply: (state: ReplyPending) => Effect.Effect<string, AdapterError | ReplyRejected>;

/**
* Searches for the exact pending reply in case it was posted before the
* corresponding `ReplyPosted` state could be persisted.
*
* Returns its adapter-encoded source URI when found, or `null` otherwise.
*/
readonly findPostedReply: (state: ReplyPending) => Effect.Effect<string | null, AdapterError>;
}

/**
* One tag for every platform. A processor resolves its adapter from the context it is built in, so each one is given the implementation for its own platform.
*/
export const NTBSAdapter = Context.Service<NTBSAdapter>("t3code/ntbs/adapter");
Loading
Loading